from django.contrib import admin from django.core import validators from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import F, Q from django.db.models.functions import Chr, Concat, Ord from membershipworks.models import Member class LockerBank(models.Model): """A set of locker units, placed together""" name = models.CharField(max_length=200) location = models.CharField(max_length=200) slug = models.SlugField(unique=True) def __str__(self): return f"{self.name} ({self.units.count()} units)" class LockerUnit(models.Model): """A standalone set of lockers""" bank = models.ForeignKey( LockerBank, on_delete=models.SET_NULL, related_name="units", null=True ) index = models.PositiveIntegerField() first_letter = models.CharField( max_length=1, validators=[validators.RegexValidator("[A-Z]")], unique=True ) first_number = models.PositiveIntegerField() rows = models.PositiveIntegerField(default=5) columns = models.PositiveIntegerField(default=2) def save(self, *args, **kwargs): if self._state.adding: # Create LockerInfo for each locker with transaction.atomic(): super().save(self, *args, **kwargs) for column in range(self.columns): for row in range(self.rows): self.lockers.create(column=column + 1, row=row + 1) else: super().save(self, *args, **kwargs) class Meta: # TODO: add constraint to check for letter overlaps constraints = [ models.UniqueConstraint(fields=["bank", "index"], name="unique_bank_index") ] ordering = ["index"] def __str__(self): last_letter = chr(ord(self.first_letter) + self.columns - 1) last_number = self.first_number + self.columns * self.rows return f"{self.bank.name} (Unit {last_letter}{self.first_number}-{self.first_letter}{last_number})" def letter_for_column(self, column: int) -> str: return chr(column + ord(self.first_letter)) def number_for_locker(self, column: int, row: int) -> int: return row + self.first_number + (self.columns - column - 1) * self.rows # TODO: add check constraint to ensure that column and number are within locker_unit bounds class LockerInfo(models.Model): """Information about a single locker""" locker_unit = models.ForeignKey( LockerUnit, on_delete=models.CASCADE, related_name="lockers" ) column = models.PositiveIntegerField() row = models.PositiveIntegerField() blind_code = models.CharField( max_length=5, help_text="Stamped on some keys. Usually D###A.", blank=True, ) bitting_code = models.CharField( max_length=5, help_text="National Disc Tumbler, depths 1-4. Read bow-to-tip.", blank=True, ) renter = models.ForeignKey( Member, on_delete=models.CASCADE, null=True, blank=True, db_constraint=False ) reserved = models.BooleanField( default=False, help_text="Locker is reserved for MakerSpace use, and cannot be rented.", ) notes = models.TextField(blank=True) def clean(self): if self.reserved and self.renter is not None: raise ValidationError("Locker cannot both be reserved and rented!") class Meta: constraints = [ models.UniqueConstraint( fields=["locker_unit", "column", "row"], name="unique_locker_info" ), models.CheckConstraint( check=Q(reserved=False) | Q(renter__isnull=True), name="locker_not_reserved_and_rented", ), ] @property def available(self) -> bool: return self.renter is None and not self.reserved @property def letter(self) -> str: return self.locker_unit.letter_for_column(self.column) @property def number(self) -> int: return self.locker_unit.number_for_locker(self.column, self.row) @property @admin.display( description="Address", ordering=Concat( Chr(F("column") + Ord("locker_unit__first_letter")), ( F("row") + F("locker_unit__first_number") + (F("locker_unit__columns") - F("column") - 1) * F("locker_unit__rows") ), ), ) def address(self) -> str: return f"{self.letter}{self.number}" def __str__(self): return f"{self.locker_unit}-{self.address} [{self.renter}]"