# content/models.py import uuid from decimal import Decimal from io import BytesIO from urllib.parse import urlparse, parse_qs, unquote from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.mail import send_mail from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator from django.db import models, transaction from django.db.models import F, Q from django.urls import reverse from django.utils import timezone import re from django.db.models.signals import post_save, post_delete, post_migrate from django.dispatch import receiver from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas User = settings.AUTH_USER_MODEL # For relations (FK/O2O) — string label is safest across apps/migrations USER_MODEL = settings.AUTH_USER_MODEL # For querying/creating users in code UserModel = get_user_model() class TimeStampedModel(models.Model): created_at = models.DateTimeField(default=timezone.now, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) class Meta: abstract = True class ActiveQuerySet(models.QuerySet): def active(self): return self.filter(is_active=True) class ActiveManager(models.Manager): def get_queryset(self): return ActiveQuerySet(model=self.model, using=self._db) def active(self): return self.get_queryset().active() class ImageUrlMixin: @property def image_src(self): try: f = getattr(self, "image", None) if f and f.url: # tolerate missing files in prod return f.url except Exception: pass return getattr(self, "image_url", "") or "" def branding_upload_to(instance, filename): dt = timezone.now() return f"branding/{dt:%Y/%m}/{filename}" class SiteBranding(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ Global site branding you can reuse anywhere (logo, favicon, site name). Create one active record; the latest active is used by templates. """ is_active = models.BooleanField(default=True, help_text="The latest active record is used site-wide.") site_name = models.CharField( max_length=160, blank=True, help_text="Optional site title/brand text (fallback if logo missing)." ) # Logo: either upload a file or paste a URL (file wins) logo = models.ImageField(upload_to=branding_upload_to, blank=True, null=True) logo_url = models.URLField(blank=True) # Favicon (optional) favicon = models.ImageField(upload_to=branding_upload_to, blank=True, null=True) favicon_url = models.URLField(blank=True) # Alt text (accessibility) logo_alt = models.CharField(max_length=200, blank=True, default="Site logo") created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) class Meta: ordering = ("-updated_at", "-id") verbose_name = "Site Branding" verbose_name_plural = "Site Branding" def __str__(self): return self.site_name or f"Branding #{self.pk}" @property def logo_src(self) -> str: try: if self.logo and self.logo.url: return self.logo.url except Exception: pass return self.logo_url or "" @property def favicon_src(self) -> str: try: if self.favicon and self.favicon.url: return self.favicon.url except Exception: pass return self.favicon_url or "" def banner_upload_to(instance, filename): return f"banners/{timezone.now():%Y/%m}/{filename}" def notice_upload_to(instance, filename): return f"notices/{timezone.now():%Y/%m}/{filename}" # ////////////////////////////////// # Banner # ////////////////////////////////// class Banner(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """Homepage slider banner with optional image file or external URL.""" title = models.CharField( max_length=150, help_text="Main headline shown on the banner." ) subtitle = models.CharField( max_length=300, blank=True, help_text="Optional sub-headline under the title." ) image = models.ImageField( upload_to=banner_upload_to, blank=True, null=True, help_text="Preferred ~1920×600 JPG/PNG. Ignored if Image URL is set." ) image_url = models.URLField( blank=True, help_text="External image URL (used if no uploaded image or if set)." ) button_text = models.CharField( max_length=40, blank=True, help_text="Optional CTA text (e.g. ‘Apply now’)." ) button_link = models.CharField( max_length=300, blank=True, help_text="Internal path or full URL (e.g. /admission or https://example.com)." ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first." ) is_active = models.BooleanField( default=True, help_text="Uncheck to hide this banner." ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="banners_created" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ("order", "-created_at") def __str__(self): return self.title @property def image_src(self) -> str: """Prefer uploaded file; fall back to external URL; never crash.""" if self.image: try: return self.image.url except Exception: pass return self.image_url or "" def clean(self): """Trim fields and require at least one image source.""" for f in ("title", "subtitle", "button_text", "button_link", "image_url"): v = getattr(self, f, "") if isinstance(v, str): setattr(self, f, v.strip()) if not self.image and not (self.image_url or "").strip(): raise ValidationError("Provide an image file or an image URL.") # ////////////////////////////////// # Notice # ////////////////////////////////// class Notice(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() title = models.CharField( max_length=200, help_text="Notice title shown on the card." ) subtitle = models.TextField( blank=True, help_text="Optional short description shown under the title." ) image = models.ImageField( upload_to=notice_upload_to, blank=True, null=True, help_text="Optional image for the notice card." ) image_url = models.URLField( blank=True, help_text="Optional external image URL (used if no uploaded image)." ) link_url = models.URLField( blank=True, help_text="Optional ‘Read more’ target; if empty, the internal detail page is used." ) published_at = models.DateTimeField( default=timezone.now, help_text="Publish date/time (controls ordering). Format: YYYY-MM-DD HH:MM (24-hour)." ) is_active = models.BooleanField( default=True, help_text="Uncheck to hide this notice." ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ("-published_at", "-created_at") def __str__(self): return self.title @property def image_src(self) -> str: if getattr(self, "image", None): try: return self.image.url except Exception: pass return getattr(self, "image_url", "") or "" def get_absolute_url(self) -> str: return reverse("notice_detail", args=[self.pk]) @property def url(self) -> str: lu = getattr(self, "link_url", "") or "" return lu if lu.strip() else self.get_absolute_url() # ////////////////////////////////// # Timeline # ////////////////////////////////// class TimelineEvent(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() title = models.CharField( max_length=150, help_text="Event title (shown on the timeline)." ) description = models.TextField( blank=True, help_text="Optional short description under the title." ) date = models.DateField( help_text="Event date (YYYY-MM-DD)." ) order = models.PositiveIntegerField( default=0, help_text="Secondary sort within the same date. Lower appears first." ) is_active = models.BooleanField( default=True, help_text="Uncheck to hide this event." ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="timeline_created" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ("date", "order", "-created_at") def __str__(self): return f"{self.date} — {self.title}" # ////////////////////////////////// # Gallery # ////////////////////////////////// class GalleryItem(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() IMAGE = "image" VIDEO = "video" KIND_CHOICES = [(IMAGE, "Image"), (VIDEO, "YouTube (embed)")] kind = models.CharField( max_length=10, choices=KIND_CHOICES, default=IMAGE, help_text="Choose ‘Image’ to upload a photo or ‘YouTube (embed)’ for a video." ) title = models.CharField( max_length=200, help_text="Display title for this item." ) place = models.CharField( max_length=120, blank=True, help_text="Optional location (e.g., ‘Main Hall’)." ) taken_at = models.DateTimeField( null=True, blank=True, help_text="When the photo/video was taken. Format: YYYY-MM-DD HH:MM (24-hour)." ) # media image = models.ImageField( upload_to="gallery/", blank=True, help_text="Upload if Kind=Image. Recommended ~1600px on the long side." ) youtube_embed_url = models.URLField( blank=True, help_text="For Kind=YouTube. You can paste either an embed URL or a watch URL " "(e.g., https://www.youtube.com/embed/ScMzIvxBSi4 or " "https://www.youtube.com/watch?v=ScMzIvxBSi4)." ) thumbnail = models.ImageField( upload_to="gallery/thumbs/", blank=True, help_text="Optional custom thumbnail for grids (e.g., 600×400). If empty, a YouTube thumbnail or the main image is used." ) # housekeeping order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first." ) is_active = models.BooleanField( default=True, help_text="Uncheck to hide this item." ) class Meta: ordering = ["order", "-taken_at", "-id"] def __str__(self): return self.title # --- convenience --- @property def date_str(self): return self.taken_at.strftime("%Y-%m-%d") if self.taken_at else "" @property def time_str(self): # Portable (Windows-safe) 12-hour format without leading zero return self.taken_at.strftime("%I:%M %p").lstrip("0") if self.taken_at else "" def _youtube_id(self): if not self.youtube_embed_url: return "" m = re.search(r"(?:embed/|v=)([A-Za-z0-9_-]{11})", self.youtube_embed_url) return m.group(1) if m else "" @property def thumb_src(self) -> str: # priority: explicit thumbnail > youtube thumb > image if getattr(self, "thumbnail", None): try: return self.thumbnail.url except Exception: pass if self.kind == self.VIDEO: vid = self._youtube_id() if vid: return f"https://img.youtube.com/vi/{vid}/hqdefault.jpg" if getattr(self, "image", None): try: return self.image.url except Exception: pass return "" def clean(self): """Basic sanity: require media for selected kind.""" if self.kind == self.IMAGE and not self.image: raise ValidationError("For Kind=Image, please upload an image.") if self.kind == self.VIDEO and not (self.youtube_embed_url or "").strip(): raise ValidationError("For Kind=YouTube, please provide a YouTube URL.") # ////////////////////////////////// # About # ////////////////////////////////// def about_upload_to(instance, filename): return f"about/{timezone.now():%Y/%m}/{filename}" class AboutSection(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ One editable 'About the College' block with up to 4 fading images. (Single model by request—no separate image table.) """ # --- content --- title = models.CharField( max_length=150, help_text="Section heading shown above the block (e.g., 'About My College')." ) college_name = models.CharField( max_length=150, blank=True, help_text="Optional sub-heading inside the block (e.g., your college name)." ) body = models.TextField( blank=True, help_text="Main paragraph text. Keep it concise." ) bullets = models.TextField( blank=True, help_text="Bullet points — one per line. Example:\nSmart Classrooms\nExperienced Faculty\nModern Labs" ) # --- images (up to 4, optional) --- image_1 = models.ImageField( upload_to=about_upload_to, blank=True, null=True, help_text="Fading image #1 (landscape recommended)." ) image_1_alt = models.CharField( max_length=200, blank=True, help_text="Alt text for image #1 (accessibility)." ) image_2 = models.ImageField( upload_to=about_upload_to, blank=True, null=True, help_text="Fading image #2 (optional)." ) image_2_alt = models.CharField( max_length=200, blank=True, help_text="Alt text for image #2 (accessibility)." ) image_3 = models.ImageField( upload_to=about_upload_to, blank=True, null=True, help_text="Fading image #3 (optional)." ) image_3_alt = models.CharField( max_length=200, blank=True, help_text="Alt text for image #3 (accessibility)." ) image_4 = models.ImageField( upload_to=about_upload_to, blank=True, null=True, help_text="Fading image #4 (optional)." ) image_4_alt = models.CharField( max_length=200, blank=True, help_text="Alt text for image #4 (accessibility)." ) # --- ordering/visibility --- order = models.PositiveIntegerField(default=0, help_text="Lower comes first.") is_active = models.BooleanField(default=True, help_text="Uncheck to hide this section.") # --- housekeeping --- created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) class Meta: ordering = ("order", "-updated_at") verbose_name = "About Section" verbose_name_plural = "About Sections" def __str__(self): return self.title @property def bullet_list(self): """Return bullets as a cleaned list (skip blanks).""" return [b.strip() for b in (self.bullets or "").splitlines() if b.strip()] @property def image_list(self): """ Returns a list of (url, alt) for all present images, in order. Useful in templates for the fading stack. """ out = [] for idx in (1, 2, 3, 4): img = getattr(self, f"image_{idx}", None) if img: try: url = img.url except Exception: url = "" if url: alt = getattr(self, f"image_{idx}_alt", "") or "" out.append((url, alt)) return out @property def image_count(self): return len(self.image_list) # ////////////////////////////////// # ACADEMIC CALENDAR # ////////////////////////////////// class AcademicCalendarItem(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() TONE_CHOICES = [ ("blue", "Blue"), ("green", "Green"), ("red", "Red"), ("purple", "Purple"), ("orange", "Orange"), ] # Content title = models.CharField( max_length=150, help_text="Short heading (e.g., ‘Semester Start’)." ) date_text = models.CharField( max_length=150, help_text="Human-friendly date or range (e.g., ‘10 Sep 2025’ or ‘20 Dec 2025 – 5 Jan 2026’)." ) description = models.TextField( blank=True, help_text="Optional brief description shown under the date." ) # Appearance icon_class = models.CharField( max_length=80, default="bi bi-calendar-event", help_text=( "CSS class for the icon.\n" "• Bootstrap Icons (load BI in base.html): e.g. bi bi-calendar-week\n" "• Font Awesome (if you load FA): e.g. fa-solid fa-calendar-days" ), ) tone = models.CharField( max_length=10, choices=TONE_CHOICES, default="blue", help_text="Color accent for the round icon badge." ) # Placement / lifecycle order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first." ) is_active = models.BooleanField( default=True, help_text="Uncheck to hide this item from the site." ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="calendar_items", help_text="Auto-filled on first save." ) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) class Meta: ordering = ("order", "-updated_at") verbose_name = "Academic Calendar Item" verbose_name_plural = "Academic Calendar Items" def __str__(self): return self.title @property def icon_tone_class(self) -> str: return f"icon-{self.tone}" # ////////////////////////////////// # Programs & Courses # ////////////////////////////////// def course_image_upload_to(instance, filename): """ Upload path for a course's cover image. Kept simple so the path is stable even before the instance is saved. """ return f"courses/{instance.category}/{filename}" def course_syllabus_upload_to(instance, filename): """ Upload path for a course's syllabus file (PDF/Doc, etc.). """ return f"courses/syllabi/{instance.category}/{filename}" class Course(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ A program that students can enroll into (e.g., Science, Commerce). Use the fee fields to publish a transparent, itemized cost breakdown in the frontend and to snapshot fees into applications. """ CATEGORY_CHOICES = [ ("science", "Science"), ("commerce", "Commerce"), ("arts", "Arts"), ("vocational", "Vocational"), ] title = models.CharField( max_length=150, help_text="Public name of the course as shown on the website.", ) category = models.CharField( max_length=20, choices=CATEGORY_CHOICES, help_text="Bucket used for filtering and grouping courses.", ) image = models.ImageField( upload_to=course_image_upload_to, blank=True, null=True, help_text="Optional cover image for cards/headers (recommended landscape).", ) syllabus_file = models.FileField( upload_to=course_syllabus_upload_to, blank=True, null=True, help_text="Attach a PDF/DOC syllabus students can download.", ) duration = models.CharField( max_length=50, blank=True, help_text="e.g., '2 years', 'Jan–Dec', or 'Semester based'.", ) shift = models.CharField( max_length=50, blank=True, help_text="e.g., 'Morning', 'Day', 'Evening' (optional).", ) description = models.TextField( blank=True, help_text="A short overview of what the course covers and outcomes.", ) eligibility = models.CharField( max_length=200, blank=True, help_text="Minimum requirements (e.g., 'SSC pass with GPA ≥ 3.5').", ) # >>> Fee fields you will set in admin <<< admission_fee = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="One-time admission/registration fee.", ) first_month_tuition = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Tuition fee for the first month (or initial installment).", ) exam_fee = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Internal/board exam fee (if applicable).", ) bus_fee = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Optional transport/bus fee (per month or fixed).", ) hostel_fee = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Optional hostel/accommodation fee.", ) marksheet_fee = models.DecimalField( max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Optional marksheet/certification processing fee.", ) # (optional legacy) monthly_fee = models.DecimalField( max_digits=10, decimal_places=2, blank=True, null=True, help_text="Legacy field. Prefer the explicit fee fields above.", ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first in listings.", ) is_active = models.BooleanField( default=True, help_text="Untick to hide this course from the website.", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="courses_created", on_delete=models.SET_NULL, null=True, blank=True, editable=False, help_text="User who created this record (set automatically).", ) created_at = models.DateTimeField( auto_now_add=True, editable=False, help_text="When this record was created.", ) updated_at = models.DateTimeField( auto_now=True, editable=False, help_text="When this record was last updated.", ) class Meta: ordering = ("order", "title") verbose_name = "Course" verbose_name_plural = "Courses" def __str__(self): return self.title # ---------- Admissions ---------- def admission_photo_upload_to(instance, filename): """ Upload path for applicant photos. Uses current timestamp folders so it works before the object is saved to DB. """ dt = timezone.now() return f"admissions/photos/{dt:%Y/%m}/{filename}" def admission_doc_upload_to(instance, filename): """ Upload path for applicant transcripts/attachments. """ dt = timezone.now() return f"admissions/docs/{dt:%Y/%m}/{filename}" class AdmissionApplication(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ A student's online admission form. We snapshot course fees into the application so that later fee changes in the Course model won't affect past applications. """ # --- applicant info --- full_name = models.CharField(max_length=200, help_text="Applicant’s full legal name.") email = models.EmailField(blank=True, help_text="Applicant’s email (optional, but helps for communication).") phone = models.CharField(max_length=20, help_text="Primary contact number (WhatsApp preferred if available).") date_of_birth = models.DateField(blank=True, null=True, help_text="Optional, used for record verification.") address = models.TextField(blank=True, help_text="Present address with district/upazila for correspondence.") guardian_name = models.CharField(max_length=200, blank=True, help_text="Parent/guardian full name (optional).") guardian_phone = models.CharField(max_length=20, blank=True, help_text="Parent/guardian phone number (optional).") desired_course = models.ForeignKey( "content.Course", on_delete=models.PROTECT, related_name="applications", null=True, blank=True, help_text="The course the applicant is applying to.", ) shift = models.CharField(max_length=20, blank=True, help_text="Preferred shift (Morning/Day/Evening).") previous_school = models.CharField(max_length=200, blank=True, help_text="Last attended school/college (optional).") ssc_gpa = models.DecimalField(max_digits=3, decimal_places=2, blank=True, null=True, help_text="Secondary exam GPA (or equivalent), if applicable.") photo = models.ImageField(upload_to=admission_photo_upload_to, blank=True, null=True, help_text="Passport-size photo (optional in demo).") transcript = models.FileField(upload_to=admission_doc_upload_to, blank=True, null=True, help_text="Academic transcript/certificate (optional).") message = models.TextField(blank=True, help_text="Any additional information or questions.") # --- user-selectable add-ons (optional) --- add_bus = models.BooleanField(default=False, help_text="Applicant opts for transport service.") add_hostel = models.BooleanField(default=False, help_text="Applicant opts for hostel/accommodation.") add_marksheet = models.BooleanField(default=False, help_text="Applicant opts for marksheet/certification processing.") # --- mandatory base rows (non-editable on forms) --- add_admission = models.BooleanField(default=True, editable=False, help_text="Always included: Admission fee row.") add_tuition = models.BooleanField(default=True, editable=False, help_text="Always included: First Month Tuition row.") add_exam = models.BooleanField(default=True, editable=False, help_text="Always included: Exam fee row.") # --- fee snapshots (copied from Course at submit/change) --- fee_admission = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) fee_tuition = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) fee_exam = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) fee_bus = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) fee_hostel = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) fee_marksheet = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00")) # --- totals (computed) --- fee_base_subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Admission + First Month Tuition + Exam.") fee_selected_total = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Total of all selected rows (base + add-ons).") fee_total = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"), help_text="Legacy mirror of fee_selected_total.") # --- payment tracking --- PAYMENT_STATUS = [("pending", "Pending"), ("paid", "Paid"), ("failed", "Failed")] payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS, default="pending") payment_provider = models.CharField(max_length=30, blank=True) payment_txn_id = models.CharField(max_length=100, blank=True) paid_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) # --- enrollment targets (for profile creation) --- enroll_class = models.ForeignKey("content.AcademicClass", on_delete=models.PROTECT, null=True, blank=True) enroll_section = models.CharField(max_length=20, blank=True) generated_roll = models.PositiveIntegerField(null=True, blank=True) # --------- helpers --------- def _next_roll(self): from django.db.models import Max if not self.enroll_class: return None q = StudentProfile.objects.filter(school_class=self.enroll_class) if self.enroll_section: q = q.filter(section__iexact=self.enroll_section) last = q.aggregate(m=Max("roll_number"))["m"] or 0 return last + 1 # --- snapshot & totals --- def _snapshot_fees_from_course(self, force: bool = False): """ Copy current Course fee fields into this application once, or again if force=True (e.g. course changed before approval). """ if not self.desired_course_id: return c = self.desired_course all_zero = all( Decimal(getattr(self, f, 0) or 0) == 0 for f in ("fee_admission", "fee_tuition", "fee_exam", "fee_bus", "fee_hostel", "fee_marksheet") ) if not force and not all_zero: return self.fee_admission = Decimal(c.admission_fee or 0) self.fee_tuition = Decimal(c.first_month_tuition or 0) self.fee_exam = Decimal(c.exam_fee or 0) self.fee_bus = Decimal(c.bus_fee or 0) self.fee_hostel = Decimal(c.hostel_fee or 0) self.fee_marksheet = Decimal(c.marksheet_fee or 0) def _recompute_totals(self): adm = Decimal(self.fee_admission or 0) tui = Decimal(self.fee_tuition or 0) exm = Decimal(self.fee_exam or 0) bus = Decimal(self.fee_bus or 0) if self.add_bus else Decimal("0.00") hst = Decimal(self.fee_hostel or 0) if self.add_hostel else Decimal("0.00") mks = Decimal(self.fee_marksheet or 0) if self.add_marksheet else Decimal("0.00") self.fee_base_subtotal = adm + tui + exm self.fee_selected_total = self.fee_base_subtotal + bus + hst + mks self.fee_total = self.fee_selected_total # legacy mirror # --- validation & save --- def clean(self): super().clean() # enforce mandatory base rows in all pathways self.add_admission = True self.add_tuition = True self.add_exam = True def save(self, *args, **kwargs): # Force mandatory base rows ON self.add_admission = True self.add_tuition = True self.add_exam = True # If new OR course changed, resnapshot fees course_changed = False if self.pk: try: old = type(self).objects.only("desired_course_id").get(pk=self.pk) course_changed = (old.desired_course_id != self.desired_course_id) except type(self).DoesNotExist: course_changed = True else: course_changed = True if self.desired_course_id: self._snapshot_fees_from_course(force=course_changed) # Totals from snapshot + selections self._recompute_totals() super().save(*args, **kwargs) @transaction.atomic def approve(self, by_user=None, create_first_month_invoice=True): """ Create/attach user, create StudentProfile, auto-assign roll, and create invoices: • One-time custom: Admission (mandatory), Exam (mandatory), Marksheet (optional) • Current month: Tuition (mandatory) + Bus/Hostel add-ons if chosen """ from django.contrib.auth import get_user_model User = get_user_model() # 1) Find or create the User for this applicant user = None if getattr(self, "email", None): user = User.objects.filter(email__iexact=self.email).first() if not user and getattr(self, "phone", None): user = User.objects.filter(username__iexact=self.phone).first() if not user: username = (self.email or self.phone or f"student-{timezone.now().timestamp()}").split("@")[0] user = User.objects.create( username=username, email=(self.email or None), first_name=(self.full_name or "").split(" ")[0], last_name=" ".join((self.full_name or "").split(" ")[1:]), ) if getattr(user, "role", None) and user.role != "STUDENT": user.role = "STUDENT" user.save(update_fields=["role"]) # 2) Create/Update StudentProfile with next roll sp, _ = StudentProfile.objects.get_or_create( user=user, defaults={ "school_class": self.enroll_class, "section": self.enroll_section or "", "roll_number": self._next_roll() or 1, "joined_on": timezone.now().date(), }, ) changed = False if self.enroll_class and sp.school_class_id != self.enroll_class_id: sp.school_class = self.enroll_class; changed = True if self.enroll_section and (sp.section or "") != (self.enroll_section or ""): sp.section = self.enroll_section; changed = True if not sp.roll_number: sp.roll_number = self._next_roll() or 1; changed = True if changed: sp.save() # store roll back onto the application if sp.roll_number and self.generated_roll != sp.roll_number: self.generated_roll = sp.roll_number # ---------- FEES FROM SNAPSHOT ---------- adm_fee = Decimal(self.fee_admission or 0) tui_fee = Decimal(self.fee_tuition or 0) exam_fee = Decimal(self.fee_exam or 0) # Add-ons (optional, monthly) bus_on = bool(self.add_bus) hostel_on = bool(self.add_hostel) marksheet_on = bool(self.add_marksheet) bus_fee_m = Decimal(self.fee_bus or 0) hostel_fee_m = Decimal(self.fee_hostel or 0) marksheet_fee = Decimal(self.fee_marksheet or 0) # one-time # ---------- persist selections on the profile for future months ---------- sp.has_bus_service = bus_on sp.has_hostel_seat = hostel_on sp.bus_monthly_fee = bus_fee_m if bus_on else Decimal("0.00") sp.hostel_monthly_fee = hostel_fee_m if hostel_on else Decimal("0.00") sp.save(update_fields=["has_bus_service", "has_hostel_seat", "bus_monthly_fee", "hostel_monthly_fee"]) # ---------- one-time custom invoices (mandatory base) ---------- today = timezone.localdate() _ensure_custom_invoice(user, "Admission Fee", adm_fee, due_date=today) _ensure_custom_invoice(user, "Exam Fee", exam_fee, due_date=today) # Marksheet is optional (one-time) if marksheet_on and marksheet_fee > 0: _ensure_custom_invoice(user, "Exact Marksheet", marksheet_fee, due_date=today) # ---------- this month’s tuition (mandatory) with add-ons merged ---------- if create_first_month_invoice: month_total = tui_fee + (bus_fee_m if bus_on else 0) + (hostel_fee_m if hostel_on else 0) _ensure_monthly_invoice(user, today.year, today.month, month_total, due_date=today.replace(day=28)) # 5) mark approved if self.payment_status == "pending": self.payment_status = "approved" self.save(update_fields=["payment_status", "generated_roll"]) return sp class Meta: ordering = ("-created_at",) verbose_name = "Admission Application" verbose_name_plural = "Admission Applications" def __str__(self): return f"{self.full_name} — {self.desired_course}" # --- payment helper --- def mark_paid(self, provider: str, txn_id: str): self.payment_provider = provider self.payment_txn_id = txn_id self.payment_status = "paid" self.paid_at = timezone.now() self.save(update_fields=["payment_provider", "payment_txn_id", "payment_status", "paid_at"]) def _ensure_custom_invoice(student, title, amount, due_date=None): """ Idempotent custom invoice (one-time). If an unpaid matching title exists, reuse it; otherwise create it. """ amount = Decimal(amount or 0) if amount <= 0: return None qs = TuitionInvoice.objects.filter( student=student, kind="custom", title=title, paid_amount__lt=F("tuition_amount") ) inv = qs.first() if inv: return inv return TuitionInvoice.objects.create( student=student, kind="custom", title=title, tuition_amount=amount, due_date=due_date or timezone.localdate(), ) def _ensure_monthly_invoice(student, year, month, amount, due_date=None): """ Unique monthly invoice (thanks to the UniqueConstraint). If it exists and is still unpaid, keep it in sync with the computed amount. """ amount = Decimal(amount or 0) inv, created = TuitionInvoice.objects.get_or_create( student=student, kind="monthly", period_year=year, period_month=month, defaults={ "tuition_amount": amount, "due_date": due_date or timezone.localdate().replace(day=28), }, ) if not created and inv.paid_amount == 0 and amount > 0 and inv.tuition_amount != amount: inv.tuition_amount = amount if not inv.due_date: inv.due_date = due_date or timezone.localdate().replace(day=28) inv.save(update_fields=["tuition_amount", "due_date"]) return inv # ---------- Function Highlights ---------- class FunctionHighlight(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ A single highlight block for college functions/events. One image per item; provides a simple alternating left/right layout on the homepage. """ title = models.CharField( max_length=200, help_text="Headline for the function highlight (shown over the image).", ) image = models.ImageField( upload_to="functions/", help_text="Primary image for this highlight.", ) place = models.CharField( max_length=200, blank=True, help_text="Venue/location (optional).", ) date_text = models.CharField( max_length=120, blank=True, help_text="Human-friendly date (e.g., 'December 20, 2025').", ) time_text = models.CharField( max_length=120, blank=True, help_text="Human-friendly time (e.g., '4:00 PM onwards').", ) description = models.TextField( blank=True, help_text="Short description shown beside the image.", ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first.", ) is_active = models.BooleanField( default=True, help_text="Untick to hide this highlight from the site.", ) class Meta: ordering = ["order", "-id"] verbose_name = "Function Highlight" verbose_name_plural = "Function Highlights" def __str__(self): return self.title @property def image_src(self): """Return a safe URL for the image (empty string if unavailable).""" if self.image: try: return self.image.url except Exception: pass return "" # ---------- College Festivals ---------- class CollegeFestival(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ A festival page/card containing an optional hero (image/video/YouTube), description, and a gallery of media items (see FestivalMedia). """ title = models.CharField( max_length=200, help_text="Festival title as shown on cards and headers.", ) slug = models.SlugField( unique=True, help_text="Unique slug for links. Auto-fill from title if unsure.", ) place = models.CharField( max_length=200, blank=True, help_text="Venue/location (optional).", ) date_text = models.CharField( max_length=100, blank=True, help_text="Friendly date (e.g., 'Feb 15, 2025').", ) time_text = models.CharField( max_length=100, blank=True, help_text="Friendly time (e.g., '6:00 PM onwards').", ) description = models.TextField( blank=True, help_text="Festival summary/notes.", ) # Optional hero hero_image = models.ImageField( upload_to="festivals/hero/", blank=True, null=True, help_text="Hero image for the festival card/header (optional).", ) hero_video = models.FileField( upload_to="festivals/video/", blank=True, null=True, help_text="Upload an MP4 (or similar) to play in modal (optional).", ) hero_youtube_url = models.URLField( blank=True, help_text="Paste any YouTube link (watch/shorts/embed/youtu.be).", ) is_active = models.BooleanField( default=True, help_text="Untick to hide this festival.", ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first across all festivals.", ) created_by = models.ForeignKey( User, null=True, blank=True, on_delete=models.SET_NULL, related_name="festivals_created", help_text="User who created this record (optional).", ) created_at = models.DateTimeField( auto_now_add=True, help_text="When this festival record was created.", ) updated_at = models.DateTimeField( auto_now=True, help_text="When this festival record was last updated.", ) class Meta: ordering = ("order", "-created_at") verbose_name = "College Festival" verbose_name_plural = "College Festivals" def __str__(self): return self.title class FestivalMedia(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ A single media item belonging to a CollegeFestival. Supports both images and YouTube links with an optional custom thumbnail. """ KIND_CHOICES = ( ("image", "Image"), ("youtube", "YouTube"), ) festival = models.ForeignKey( CollegeFestival, on_delete=models.CASCADE, related_name="media_items", help_text="Festival this media belongs to.", ) kind = models.CharField( max_length=20, choices=KIND_CHOICES, default="image", help_text="Choose 'Image' for uploads or 'YouTube' for embedded videos.", ) image = models.ImageField( upload_to="festivals/gallery/", blank=True, null=True, help_text="Upload the image (required if kind=Image).", ) youtube_url = models.URLField( blank=True, help_text="Paste any YouTube link (required if kind=YouTube).", ) thumbnail = models.ImageField( upload_to="festivals/thumbs/", blank=True, null=True, help_text="Optional thumbnail (otherwise we try to display the original).", ) caption = models.CharField( max_length=200, blank=True, help_text="Short caption or credit for the media item.", ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first within the festival gallery.", ) is_active = models.BooleanField( default=True, help_text="Untick to hide this media item.", ) class Meta: ordering = ("order", "id") verbose_name = "Festival Media" verbose_name_plural = "Festival Media" def __str__(self): base = self.caption or (self.image.name if self.image else self.youtube_url) or "item" return f"{self.festival.title} – {base}" # ---------- People ---------- class Member(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ People directory for the site: HOD, Teachers, Students, and Staff. You can optionally supply a hosted image via 'photo_url' if 'photo' is empty. """ class Role(models.TextChoices): HOD = "hod", "Head of Department" TEACHER = "teacher", "Teacher" STUDENT = "student", "Student" STAFF = "staff", "Staff" is_active = models.BooleanField( default=True, help_text="Untick to hide this member from listings.", ) order = models.PositiveIntegerField( default=0, help_text="Lower numbers appear first within the same role.", ) role = models.CharField( max_length=20, choices=Role.choices, help_text="Which group this member belongs to.", ) name = models.CharField( max_length=120, help_text="Full display name.", ) post = models.CharField( max_length=120, blank=True, help_text="Designation/position (e.g., Professor, Lab Assistant).", ) section = models.CharField( max_length=10, blank=True, help_text="Student section if relevant (e.g., 'A').", ) bio = models.TextField( blank=True, help_text="Short bio/intro (optional, shown in detail cards).", ) photo = models.ImageField( upload_to="members/", blank=True, null=True, help_text="Upload a headshot (square images look best).", ) photo_url = models.URLField( blank=True, help_text="Remote image URL if you prefer not to upload.", ) created_by = models.ForeignKey( User, null=True, blank=True, on_delete=models.SET_NULL, related_name="members_created", help_text="User who created this record (optional).", ) created_at = models.DateTimeField( auto_now_add=True, help_text="When this profile was created.", ) updated_at = models.DateTimeField( auto_now=True, help_text="When this profile was last updated.", ) class Meta: ordering = ("order", "name") verbose_name = "Member" verbose_name_plural = "Members" def __str__(self): return f"{self.name} ({self.get_role_display()})" @property def image_src(self): """Return the best available image URL (uploaded photo, else photo_url, else empty).""" try: if self.photo and self.photo.url: return self.photo.url except Exception: pass return self.photo_url or "" # ---------- Contact ---------- class ContactInfo(TimeStampedModel, ImageUrlMixin): objects = ActiveManager() """ Single source of truth for the Contact section of your site. Add one active record; the latest active one is shown on the homepage. """ is_active = models.BooleanField( default=True, help_text="Untick to stop showing this record on the site.", ) address = models.CharField( max_length=255, blank=True, help_text="Street address and city/district.", ) phone = models.CharField( max_length=100, blank=True, help_text="Primary phone number (can include multiple, separated by commas).", ) email = models.EmailField( blank=True, help_text="Public contact email.", ) hours = models.CharField( max_length=255, blank=True, help_text="Opening hours (e.g., 'Mon–Sat: 8:00 AM – 5:00 PM').", ) map_embed_src = models.TextField( blank=True, help_text="Paste a Google Maps