from datetime import date, datetime, timedelta from pytz import timezone import asyncio import aiohttp from django.db import models from django.db.models import F, Q, Count from django.contrib.auth.models import User from django.conf import settings from discord import Webhook calendrier = { "C": [ (date(2023, 10, 21), date(2023, 11, 6)), (date(2023, 12, 23), date(2024, 1, 8)), (date(2024, 2, 10), date(2024, 2, 26)), (date(2024, 4, 6), date(2024, 4, 22)), (date(2024, 5, 6), date(2024, 5, 11)), # pont un peu gratté ] } class Lycee(models.Model): uai = models.CharField(max_length=10) libelle = models.CharField(max_length=100) vacances = models.CharField(max_length=1) def __str__(self): return self.libelle class Classe(models.Model): lycee = models.ForeignKey(Lycee, on_delete=models.CASCADE) libelle = models.CharField(max_length=20) annee = models.IntegerField() jour_zero = models.DateField() def no_semaine(self, jour): """ Entrées : - self - jour (datetime.date) Sortie : - Le numéro de la semaine contenant jour, sans compter les vacances. Renvoie un numéro non spécifiée si le jour est pendant une période de vacances """ zone = self.lycee.vacances vacances = calendrier[zone] jour0 = self.jour_zero n = 1 + ((jour - jour0).days) // 7 for debut, fin in vacances: if jour > debut: n -= round((fin - debut) / timedelta(weeks=1)) return n def no_aujourdhui(self): """ Entrée: - self Sortie: - Le numéro de la semaine courante """ return self.no_semaine(date.today()) def date_debut_sem(self, n): """ Entrée: - self - n (int) <-> numéro de semaine Sortie: - Le date du lundi de la semaine n """ zone = self.lycee.vacances vacances = calendrier[zone] jour0 = self.jour_zero jour = jour0 + (n - 1) * timedelta(weeks=1) for debut, fin in vacances: if jour >= debut: jour += round((fin - debut) / timedelta(weeks=1)) * timedelta(weeks=1) return jour def periode(self, jour): """ Entrées : - self - jour (datetime.date) Sortie : - La période (si elle existe et est unique) contenant jour Exceptions: - Le jour n'est pas dans une période - Le jour est au chevauchement de deux périodes """ return Periode.objects.get(classe=self, debut__lte=jour, fin__gte=jour) def periode_actuelle(self): #return self.periode(date.today()) // ne fonctionne pas entre les périodes """ On prend la période non révolue la plus récente """ return Periode.objects \ .filter(classe=self, fin__gte=date.today()) \ .order_by("-debut") \ .first() def __str__(self): return f"{self.libelle} ({self.lycee.libelle})" class Periode(models.Model): classe = models.ForeignKey(Classe, on_delete=models.CASCADE) #critere_colle = models.ForeignKey(Critere, on_delete=models.SET_NULL, null=True) libelle = models.CharField(max_length=100) debut = models.DateField() fin = models.DateField() class Meta: ordering = ["debut"] def range_semaines(self): """ Entrée: - self Sortie: - Un range des numéros de semaine """ return range(self.classe.no_semaine(self.debut), self.classe.no_semaine(self.fin) + 1) def query_rotations(self): return (Rotation.objects .select_related("creneau", "creneau__periode") .prefetch_related("amendement_set") .filter(creneau__periode=self) .annotate(adt_plus=Count("amendement", filter=Q(amendement__est_positif=1))) .annotate(adt_minus=Count("amendement", filter=Q(amendement__est_positif=0))) .annotate(volume=F("creneau__capacite") + F("adt_plus") - F("adt_minus"))) def query_rotations_etudiant(self, etudiant): return (Rotation.objects .select_related("creneau", "creneau__periode") .prefetch_related("amendement_set") .filter(creneau__periode=self) .filter((Q(groupes__etudiant=etudiant) & ~Q(amendement__est_positif=0, amendement__etudiant=etudiant)) | Q(amendement__est_positif=1, amendement__etudiant=etudiant)) .annotate(adt_plus=Count("amendement", filter=Q(amendement__est_positif=1))) .annotate(adt_minus=Count("amendement", filter=Q(amendement__est_positif=0))) .annotate(volume=F("creneau__capacite") + F("adt_plus") - F("adt_minus"))) def query_rotations_not_full(self): return (self.query_rotations() .filter(volume__lt=F("creneau__capacite"), date__gte=date.today())) def __str__(self): return self.libelle class Matiere(models.Model): classe = models.ForeignKey(Classe, on_delete=models.CASCADE) libelle = models.CharField(max_length=100) code = models.CharField(max_length=20) def __str__(self): return self.libelle class Critere(models.Model): periode = models.ForeignKey(Periode, on_delete=models.CASCADE) libelle = models.CharField(max_length=100) def __str__(self): return self.libelle class Groupe(models.Model): #class Meta: # ordering=[F("periode").classe.libelle, F("periode").libelle, "libelle"] periode = models.ForeignKey(Periode, on_delete=models.CASCADE) critere = models.ForeignKey(Critere, null=True, on_delete=models.CASCADE) libelle = models.CharField(max_length=100) membres = models.ManyToManyField("Etudiant", through="Appartenance") def __str__(self): return self.libelle #def get_colles(self): # return Rotation.objects.filter(creneau__periode=self.periode, # Q(groupes=self) || Q(a)).order_by("date") def get_colles_par_sem(self): semaines = ((s, self.periode.classe.date_debut_sem(s)) for s in self.periode.range_semaines()) colles_flat = self.get_colles() return [ (sem, lundi, colles_flat.filter(date__gte=lundi, date__lt=lundi + timedelta(weeks=1))) for sem, lundi in semaines ] class Etudiant(models.Model): class Meta: ordering = ["classe", "nom", "prenom"] classe = models.ForeignKey(Classe, on_delete=models.CASCADE) prenom = models.CharField(max_length=100) nom = models.CharField(max_length=100) groupes = models.ManyToManyField("Groupe", through="Appartenance") def appartient(self, groupe): """ Renvoie si self appartient au groupe. """ return groupe.membres.contains(self) def groupe_du_critere(self, periode, critere): """ Renvoie le groupe du critère auquel self appartient. """ if isinstance(critere, str): critere = Critere.objects.get(periode=periode, libelle=critere) return Appartenance.objects.get(groupe__periode=periode, etudiant=self, groupe__critere=critere).groupe def groupe_de_colle(self, periode): """ Renvoie le groupe de colle de self pendant periode. """ return self.groupe_du_critere(periode, "colle") def __str__(self): return f"{self.prenom} {self.nom}" class Appartenance(models.Model): etudiant = models.ForeignKey(Etudiant, on_delete=models.CASCADE) groupe = models.ForeignKey(Groupe, on_delete=models.CASCADE) class Colleur(models.Model): civilite = models.CharField(max_length=1) nom = models.CharField(max_length=100) def __str__(self): if self.civilite == "M": return f"M. {self.nom}" else: return f"Mme {self.nom}" def get_classes(self): return (x.periode.classe for x in Creneau.objects.filter(colleur=self).select_related("periode__classe")) class Creneau(models.Model): periode = models.ForeignKey(Periode, on_delete=models.CASCADE) jour = models.IntegerField() heure = models.TimeField() duree = models.DurationField() salle = models.CharField(max_length=20) matiere = models.ForeignKey(Matiere, on_delete=models.CASCADE) colleur = models.ForeignKey(Colleur, on_delete=models.CASCADE) est_colle = models.BooleanField() capacite = models.IntegerField() def __str__(self): jours = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"] return f"Colle {self.matiere} avec {self.colleur} {jours[self.jour]} {self.heure}" class Rotation(models.Model): class Meta: ordering = ["creneau__periode__classe", "creneau__matiere__libelle", "creneau__colleur__nom", "date", "creneau__heure"] creneau = models.ForeignKey(Creneau, on_delete=models.CASCADE) groupes = models.ManyToManyField(Groupe) date = models.DateField() def groupe_initial(self): """ Renvoie les étudiants inscrits à la colle sans prendre en compte les amendements. """ return Etudiant.objects.filter(id__in=Appartenance.objects.filter(groupe__in=self.groupes.all())) def groupe_effectif(self): """ Renvoie les étudiants inscrits à la colle en tenant compte des amendements. """ amendements = Amendement.objects.filter(rotation=self) return Etudiant.objects.filter( (Q(id__in=Appartenance.objects.filter(groupe__in=self.groupes.all())) | Q(id__in=amendements.filter(est_positif=True).values("etudiant_id"))) & ~Q(id__in=amendements.filter(est_positif=False).values("etudiant_id")) ) def effectif(self): """ Renvoie le nombre d'étudiants inscrits à la colle en tenant compte des amendements. """ n_base = sum(len(groupe.membres.count()) for groupe in self.groupes.all()) n_plus = len(Amendement.objects.filter(est_positif=True, rotation=self)) n_moins = len(Amendement.objects.filter(est_positif=False, rotation=self)) return n_base + n_plus - n_moins def est_pleine(self): """ Renvoie si la colle est pleine. """ eff = self.effectif() return eff >= self.creneau.capacite def est_modifiee(self): """ Renvoie si la colle a été amendée. """ return Amendement.objects.filter(rotation=self).exists() def amender(self, etudiant, est_positif, notifier=False): """ Amende la colle en (des)inscrivant etudiant à la colle self, selon est_positif. """ if Amendement.objects.filter(rotation=self, etudiant=etudiant, est_positif=est_positif).exists(): raise Exception("Duplication") elif Amendement.objects.filter(rotation=self, etudiant=etudiant, est_positif=not est_positif).exists(): # les amendements complémentaires s'annulent Amendement.objects.get(rotation=self, etudiant=etudiant, est_positif=not est_positif).delete() elif est_positif and any(etudiant.appartient(groupe) for groupe in self.groupes.all()): # on ne peut pas s'ajouter si on est dans le groupe de base raise Exception("Vous êtes déjà dans le groupe") elif not est_positif and all(not etudiant.appartient(groupe) for groupe in self.groupes.all()): raise Exception("Vous n'êtes pas dans le groupe") elif est_positif and self.est_pleine(): raise Exception("Capacité dépassée") else: amendement = Amendement(rotation=self, etudiant=etudiant, est_positif=est_positif) amendement.save() if notifier: loop = asyncio.run_until_complete(amendement.notifier()) def __str__(self): return f"{self.creneau} le {self.date} avec groupes {'+'.join(str(groupe) for groupe in self.groupes.all())}" def datetime(self): return datetime.combine(self.date, self.creneau.heure, tzinfo=timezone("Europe/Paris")) class Amendement(models.Model): est_positif = models.BooleanField() rotation = models.ForeignKey(Rotation, on_delete=models.CASCADE) etudiant = models.ForeignKey(Etudiant, on_delete=models.CASCADE) async def notifier(self): async with aiohttp.ClientSession() as session: webhook = Webhook.from_url(settings.DISCORD_NOTIFY_WEBHOOK_URL, session=session) if self.est_positif: await webhook.send(f"Colle réservée : {self.rotation}", username=settings.DISCORD_NOTIFY_WEBHOOK_USERNAME) else: await webhook.send(f"Colle libérée : {self.rotation}", username=settings.DISCORD_NOTIFY_WEBHOOK_USERNAME) class Profil(models.Model): utilisateur = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) etudiant = models.ForeignKey(Etudiant, null=True, blank=True, on_delete=models.SET_NULL) colleur = models.ForeignKey(Colleur, null=True, blank=True, on_delete=models.SET_NULL) def __str__(self): return f"Profil {self.utilisateur} : {self.etudiant} ; {self.colleur}" @staticmethod def from_request(request, preprocess=lambda query: query): user = request.user session = request.session match session.get("profil"): case "etudiant": profil = preprocess(Profil.objects.filter(utilisateur=user)).get() return profil.etudiant case "colleur": profil = preprocess(Profil.objects.filter(utilisateur=user)).get() return profil.colleur case _: raise ValueError("profil non choisi") class LienCalendrier(models.Model): code = models.CharField(max_length=32, unique=True) etudiant = models.ForeignKey(Etudiant, on_delete=models.CASCADE) periode = models.ForeignKey(Periode, on_delete=models.CASCADE) class Meta: constraints = [ models.UniqueConstraint( fields=['etudiant', 'periode'], name='unique_etudiant_periode_combination' ) ]