colles.mp2i-vms.fr/colloscope/models.py

422 lines
14 KiB
Python

from datetime import date, datetime, timedelta
from asgiref.sync import async_to_sync
from pytz import timezone
import aiohttp
from django.db import models
from django.db.models import F, Q, Count, QuerySet
from django.contrib.auth.models import User
from django.conf import settings
from discord import Webhook
calendar = {
"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 School(models.Model):
uai = models.CharField(max_length=10)
description = models.CharField(max_length=100)
vacation = models.CharField(max_length=1)
def __str__(self) -> str:
return self.description
class Class(models.Model):
school = models.ForeignKey(School, on_delete=models.CASCADE)
description = models.CharField(max_length=20)
year = models.IntegerField()
day_zero = models.DateField()
def week_number(self, day: date) -> int:
"""
Entrées :
- self
- day (datetime.date)
Sortie :
- Le numéro de la semaine contenant day, sans compter les vacation.
Renvoie un numéro non spécifiée si le day est pendant une période de vacation
"""
zone = self.school.vacation
vacation = calendar[zone]
day0 = self.day_zero
n = 1 + (day - day0).days // 7
for debut, fin in vacation:
if day > debut:
n -= round((fin - debut) / timedelta(weeks=1))
return n
def today_number(self) -> int:
"""
Entrée:
- self
Sortie:
- Le numéro de la semaine courante
"""
return self.week_number(date.today())
def week_beginning_date(self, n: int) -> date:
"""
Entrée:
- self
- n (int) <-> numéro de semaine
Sortie:
- Le date du lundi de la semaine n
"""
zone = self.school.vacation
vacation = calendar[zone]
day0 = self.day_zero
day = day0 + (n - 1) * timedelta(weeks=1)
for begin, end in vacation:
if day >= begin:
day += round((end - begin) / timedelta(weeks=1)) * timedelta(weeks=1)
return day
def term_of_date(self, day: date):
"""
Entrées :
- self
- day (datetime.date)
Sortie :
- La période (si elle existe et est unique) contenant day
Exceptions:
- Le day n'est pas dans une période
- Le day est au chevauchement de deux périodes
"""
return Term.objects.get(cls=self, debut__lte=day, fin__gte=day)
def current_term(self):
"""
On prend la période non révolue la plus récente
"""
return (Term.objects
.filter(cls=self, end__gte=date.today())
.order_by("-begin")
.first())
def __str__(self):
return f"{self.description} ({self.lycee.description})"
class Term(models.Model):
cls = models.ForeignKey(Class, on_delete=models.CASCADE)
description = models.CharField(max_length=100)
begin = models.DateField()
end = models.DateField()
class Meta:
ordering = ["begin"]
def range_weeks(self) -> range:
"""
Entrée:
- self
Sortie:
- Un range des numéros de semaine
"""
return range(self.cls.week_number(self.begin), self.cls.week_number(self.end) + 1)
def query_colles(self) -> QuerySet:
return (Colle.objects
.select_related("slot", "slot__term")
.prefetch_related("swap_set")
.filter(slot__term=self)
.annotate(adt_plus=Count("swap", filter=Q(swap__enroll=1)))
.annotate(adt_minus=Count("swap", filter=Q(swap__enroll=0)))
.annotate(volume=F("slot__capacity") + F("adt_plus") - F("adt_minus")))
def query_colles_of_student(self, student) -> QuerySet:
return (Colle.objects
.select_related("slot", "slot__term")
.prefetch_related("swap_set")
.filter(slot__term=self)
.filter((Q(groups__student=student)
& ~Q(swap__enroll=0, swap__student=student))
| Q(swap__enroll=1, swap__student=student))
.annotate(adt_plus=Count("swap", filter=Q(swap__enroll=1)))
.annotate(adt_minus=Count("swap", filter=Q(swap__enroll=0)))
.annotate(volume=F("slot__capacity") + F("adt_plus") - F("adt_minus")))
def query_colles_not_full(self) -> QuerySet:
return (self.query_colles()
.filter(volume__lt=F("slot__capacity"), date__gte=date.today()))
def __str__(self) -> str:
return self.description
class Subject(models.Model):
cls = models.ForeignKey(Class, on_delete=models.CASCADE)
description = models.CharField(max_length=100)
code = models.CharField(max_length=20)
def __str__(self):
return self.description
class GroupType(models.Model):
term = models.ForeignKey(Term, on_delete=models.CASCADE)
description = models.CharField(max_length=100)
def __str__(self):
return self.description
class Group(models.Model):
class Meta:
ordering = ["term__cls__description", "term__description", "description"]
term = models.ForeignKey(Term, on_delete=models.CASCADE)
type = models.ForeignKey(GroupType, null=True, on_delete=models.CASCADE)
description = models.CharField(max_length=100)
members = models.ManyToManyField("Student", through="Member")
def __str__(self):
return self.description
"""def get_colles(self):
return Rotation.objects.filter(slot__term=self.term,
Q(groupes=self) || Q(a)).order_by("date")
def get_colles_par_sem(self):
semaines = ((s, self.term.cls.week_beginning_date(s)) for s in self.term.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 Student(models.Model):
class Meta:
ordering = ["cls", "last_name", "first_name"]
cls = models.ForeignKey(Class, on_delete=models.CASCADE)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
groups = models.ManyToManyField("Group", through="Member")
def is_member(self, group):
"""
Renvoie si self appartient au groupe.
"""
return group.members.contains(self)
def group_of_type(self, term, type_):
"""
Renvoie le groupe du critère auquel self appartient.
"""
if isinstance(type_, str):
type_ = GroupType.objects.get(term=term, description=type_)
return Member.objects.get(group__term=term, student=self, group__type=type_).group
def colle_group(self, term):
"""
Renvoie le groupe de colle de self pendant term.
"""
return self.group_of_type(term, "colle")
def __str__(self):
return f"{self.first_name} {self.last_name}"
class Member(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
class Colleur(models.Model):
gender = models.CharField(max_length=1)
name = models.CharField(max_length=100)
def __str__(self):
if self.gender == "M":
return f"M. {self.name}"
else:
return f"Mme {self.name}"
def get_classes(self):
return (x.term_of_date.cls for x in Slot.objects.filter(colleur=self).select_related("term__cls"))
class Slot(models.Model):
term = models.ForeignKey(Term, on_delete=models.CASCADE)
day = models.IntegerField()
time = models.TimeField()
duration = models.DurationField()
room = models.CharField(max_length=20)
subject = models.ForeignKey(Subject, on_delete=models.CASCADE)
colleur = models.ForeignKey(Colleur, on_delete=models.CASCADE)
type = models.ForeignKey(GroupType, on_delete=models.CASCADE)
capacity = models.IntegerField()
class Meta:
verbose_name_plural = "slots"
def __str__(self):
days = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"]
return f"Colle {self.subject} avec {self.colleur} {days[self.day]} {self.time}"
class Colle(models.Model):
class Meta:
ordering = ["slot__term__cls", "slot__subject__description", "slot__colleur__name", "date",
"slot__time"]
slot = models.ForeignKey(Slot, on_delete=models.CASCADE)
groups = models.ManyToManyField(Group)
date = models.DateField()
def initial_group(self):
"""
Renvoie les étudiants inscrits à la colle sans prendre en compte les swaps.
"""
return Student.objects.filter(id__in=Member.objects.filter(groupe__in=self.groups.all()))
def final_group(self):
"""
Renvoie les étudiants inscrits à la colle en tenant compte des swaps.
"""
swaps = Swap.objects.filter(colle=self)
return Student.objects.filter(
(Q(id__in=Member.objects.filter(group__in=self.groups.all()))
| Q(id__in=swaps.filter(enroll=True).values("student_id")))
& ~Q(id__in=swaps.filter(enroll=False).values("student_id"))
)
def volume(self):
"""
Renvoie le nombre d'étudiants inscrits à la colle en tenant compte des swaps.
"""
n_base = sum(len(group.members.count()) for group in self.groups.all())
n_plus = len(Swap.objects.filter(enroll=True, colle=self))
n_moins = len(Swap.objects.filter(enroll=False, colle=self))
return n_base + n_plus - n_moins
def is_full(self):
"""
Renvoie si la colle est pleine.
"""
eff = self.volume()
return eff >= self.slot.capacity
def is_edited(self):
"""
Renvoie si la colle a été amendée.
"""
return Swap.objects.filter(colle=self).exists()
def amend(self, student, enroll, notify=False):
"""
Amende la colle en (des)inscrivant student à la colle self, selon enroll.
"""
if Swap.objects.filter(colle=self, student=student, enroll=enroll).exists():
raise Exception("Duplication")
elif Swap.objects.filter(colle=self, student=student, enroll=not enroll).exists():
# les swaps complémentaires s'annulent
Swap.objects.get(colle=self, student=student, enroll=not enroll).delete()
elif enroll and any(student.is_member(group) for group in self.groups.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 enroll and all(not student.is_member(group) for group in self.groups.all()):
raise Exception("Vous n'êtes pas dans le groupe")
elif enroll and self.is_full():
raise Exception("Capacité dépassée")
else:
swap = Swap(colle=self, student=student, enroll=enroll)
swap.save()
#if notify:
# func = async_to_sync(swap.notify)
# func()
def __str__(self):
return f"{self.slot} le {self.date} avec groupes {'+'.join(str(groupe) for groupe in self.groups.all())}"
def datetime(self):
return datetime.combine(self.date, self.slot.time, tzinfo=timezone("Europe/Paris"))
class Swap(models.Model):
enroll = models.BooleanField()
colle = models.ForeignKey(Colle, on_delete=models.CASCADE)
student = models.ForeignKey(Student, on_delete=models.CASCADE)
async def notify(self):
async with aiohttp.ClientSession() as session:
webhook = Webhook.from_url(settings.DISCORD_NOTIFY_WEBHOOK_URL, session=session)
if self.enroll:
await webhook.send(f"Colle réservée : {self.colle}", username=settings.DISCORD_NOTIFY_WEBHOOK_USERNAME)
else:
await webhook.send(f"Colle libérée : {self.colle}", username=settings.DISCORD_NOTIFY_WEBHOOK_USERNAME)
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
student = models.ForeignKey(Student, 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.user} : {self.student} ; {self.colleur}"
@staticmethod
def from_request(request, preprocess=lambda query: query):
user = request.user
session = request.session
match session.get("profile"):
case "student":
profil = preprocess(Profile.objects.filter(user=user)).get()
return profil.student
case "colleur":
profil = preprocess(Profile.objects.filter(user=user)).get()
return profil.colleur
case _:
raise ValueError("profil non choisi")
class CalendarLink(models.Model):
key = models.CharField(max_length=32, unique=True)
student = models.ForeignKey(Student, on_delete=models.CASCADE)
term = models.ForeignKey(Term, on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['student', 'term'], name='unique_student_term_combination'
)
]