diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py index 828a50fa..488330ab 100644 --- a/csm_web/scheduler/models.py +++ b/csm_web/scheduler/models.py @@ -1,486 +1,492 @@ -import datetime -import re -from django.db import models -from django.db.models.fields.related_descriptors import ReverseOneToOneDescriptor -from django.core.exceptions import ObjectDoesNotExist -from django.conf import settings -from django.contrib.auth.models import AbstractUser -from django.dispatch import receiver -from django.utils import timezone, functional -from rest_framework.serializers import ValidationError -import logging - -logger = logging.getLogger(__name__) - -logger.info = logger.warning - - -class DayOfWeekField(models.Field): - DAYS = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') - - description = "Represents a single day of the week, ordered Monday - Sunday, backed by a Postgres enum" - - def db_type(self, connection): - return 'day_of_week' - - -def day_to_number(day_of_week): - return DayOfWeekField.DAYS.index(day_of_week) - - -def week_bounds(date): - week_start = date - datetime.timedelta(days=date.weekday()) - week_end = week_start + datetime.timedelta(weeks=1) - return week_start, week_end - - -class User(AbstractUser): - priority_enrollment = models.DateTimeField(null=True, blank=True) - - def can_enroll_in_course(self, course, bypass_enrollment_time=False): - # check restricted first - if course.is_restricted and not self.is_whitelisted_for(course): - return False - - is_associated = (self.student_set.filter(active=True, section__mentor__course=course).count() or - self.mentor_set.filter(section__mentor__course=course).count()) - if bypass_enrollment_time: - return not is_associated - else: - if self.priority_enrollment: - now = timezone.now().astimezone(timezone.get_default_timezone()) - is_valid_enrollment_time = self.priority_enrollment < now < course.enrollment_end - else: - is_valid_enrollment_time = course.is_open() - return is_valid_enrollment_time and not is_associated - - def is_whitelisted_for(self, course: "Course"): - return not course.is_restricted or self.whitelist.filter(pk=course.pk).exists() - - class Meta: - indexes = (models.Index(fields=("email",)),) - - -class ValidatingModel(models.Model): - """ - By default, Django models do not validate on save! - This abstract class fixes that insanity - """ - - def save(self, *args, **kwargs): - self.full_clean() - super().save(*args, **kwargs) - - class Meta: - abstract = True - - -class ReverseOneToOneOrNoneDescriptor(ReverseOneToOneDescriptor): - def __get__(self, *args, **kwargs): - try: - return super().__get__(*args, **kwargs) - except ObjectDoesNotExist: - return None - - -class OneToOneOrNoneField(models.OneToOneField): - """ - A OneToOneField that returns None if the related object does not exist - """ - related_accessor_class = ReverseOneToOneOrNoneDescriptor - - -class Attendance(ValidatingModel): - class Presence(models.TextChoices): - PRESENT = "PR", "Present" - UNEXCUSED_ABSENCE = "UN", "Unexcused absence" - EXCUSED_ABSENCE = "EX", "Excused absence" - - presence = models.CharField(max_length=2, choices=Presence.choices, blank=True) - student = models.ForeignKey("Student", on_delete=models.CASCADE) - sectionOccurrence = models.ForeignKey("SectionOccurrence", on_delete=models.CASCADE) - - def __str__(self): - return f"{self.sectionOccurrence.date} {self.presence} {self.student.name}" - - @property - def section(self): - return self.student.section - - @property - def week_start(self): - return week_bounds(self.sectionOccurrence.date)[0] - - class Meta: - unique_together = ("sectionOccurrence", "student") - ordering = ("sectionOccurrence",) - #indexes = (models.Index(fields=("date",)),) - - -class SectionOccurrence(ValidatingModel): - """ - SectionOccurrence represents an occurrence of a section and acts as an - intermediate step between Section and Attendance. Now attendances dont - have dates but rather are associated with Section Occurrence. - """ - section = models.ForeignKey("Section", on_delete=models.CASCADE) - date = models.DateField() - word_of_the_day = models.CharField(max_length=50, blank=True) - - def __str__(self): - return f"SectionOccurrence for {self.section} at {self.date}" - - class Meta: - unique_together = ("section", "date") - ordering = ("date",) - - -class Course(ValidatingModel): - name = models.SlugField(max_length=16, unique_for_month="enrollment_start") - title = models.CharField(max_length=100) - valid_until = models.DateField() - section_start = models.DateField() - enrollment_start = models.DateTimeField() - enrollment_end = models.DateTimeField() - permitted_absences = models.PositiveSmallIntegerField() - # time limit for wotd submission; - # section occurrence date + day limit, rounded to EOD - word_of_the_day_limit = models.DurationField(null=True, blank=True) - - is_restricted = models.BooleanField(default=False) - whitelist = models.ManyToManyField("User", blank=True, related_name="whitelist") - - def __str__(self): - return self.name - - def clean(self): - super().clean() - if self.section_start <= self.enrollment_start.date(): - raise ValidationError("section_start must be after enrollment_start") - if self.enrollment_end <= self.enrollment_start: - raise ValidationError("enrollment_end must be after enrollment_start") - if self.valid_until < self.enrollment_end.date(): - raise ValidationError("valid_until must be after enrollment_end") - - # check word of the day limit is in days - if ( - isinstance(self.word_of_the_day_limit, datetime.timedelta) - and self.word_of_the_day_limit.seconds > 0 - ): - raise ValidationError("word of the day limit must be in days") - - def is_open(self): - now = timezone.now().astimezone(timezone.get_default_timezone()) - return self.enrollment_start < now < self.enrollment_end - - -class Profile(ValidatingModel): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - # all students, mentors, and coords should have a field for course - course = models.ForeignKey(Course, on_delete=models.CASCADE) - - @property - def name(self): - return self.user.get_full_name() - - def __str__(self): - return f"{self.name} ({self.course.name})" - - class Meta: - abstract = True - - -class Student(Profile): - """ - Represents a given "instance" of a student. Every section in which a student enrolls should - have a new Student profile. - """ - section = models.ForeignKey("Section", on_delete=models.CASCADE, related_name="students") - active = models.BooleanField(default=True, help_text="An inactive student is a dropped student.") - banned = models.BooleanField( - default=False, help_text="A banned student cannot enroll in another section for the course they are banned from") - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - """ - Create an attendance for this week for the student if their section hasn't already been held this week - and no attendance for this week already exists. - """ - now = timezone.now().astimezone(timezone.get_default_timezone()) - week_start = week_bounds(now.date())[0] - for spacetime in self.section.spacetimes.all(): - section_day_num = day_to_number(spacetime.day_of_week) - section_already_held = section_day_num < now.weekday() or ( - section_day_num == now.weekday() and spacetime.start_time < now.time()) - course = self.course - if self.active and course.section_start <= now.date() < course.valid_until\ - and not section_already_held and not self.attendance_set.filter(sectionOccurrence__date=week_start+datetime.timedelta(days=section_day_num)).exists(): - if settings.DJANGO_ENV != settings.DEVELOPMENT: - logger.info( - f" SO automatically created for student {self.user.email} in course {course.name} for date {now.date()}") - logger.info( - f" Attendance automatically created for student {self.user.email} in course {course.name} for date {now.date()}") - so_qs = SectionOccurrence.objects.filter( - section=self.section, date=week_start + datetime.timedelta(days=section_day_num)) - if not so_qs.exists(): - so = SectionOccurrence.objects.create( - section=self.section, date=week_start + datetime.timedelta(days=section_day_num)) - else: - so = so_qs.get() - Attendance.objects.create(student=self, sectionOccurrence=so) - - def clean(self): - super().clean() - # ensure consistency with two course fields - if self.section.mentor.course != self.course: - raise ValidationError("Student must be associated with the same course as the section they are in.") - - class Meta: - unique_together = ("user", "section") - - -class Mentor(Profile): - """ - Represents a given "instance" of a mentor. Every section a mentor teaches in every course should - have a new Mentor profile. - """ - - -class Coordinator(Profile): - """ - This profile is used to allow coordinators to acess the admin page. - """ - - def save(self, *args, **kwargs): - self.user.is_staff = True - self.user.save() - super().save(*args, **kwargs) - - def __str__(self): - return f"{self.user.get_full_name()} ({self.course.name})" - - class Meta: - unique_together = ("user", "course") - - -class Section(ValidatingModel): - # course = models.ForeignKey(Course, on_delete=models.CASCADE) - capacity = models.PositiveSmallIntegerField() - mentor = OneToOneOrNoneField(Mentor, on_delete=models.CASCADE, blank=True, null=True) - description = models.CharField( - max_length=100, - blank=True, - help_text='A brief note to add some extra information about the section, e.g. "EOP" or ' - '"early start".' - ) - - # @functional.cached_property - # def course(self): - # return self.mentor.course - - @functional.cached_property - def current_student_count(self): - return self.students.filter(active=True).count() - - def delete(self, *args, **kwargs): - if self.current_student_count and not kwargs.get('force'): - raise models.ProtectedError("Cannot delete section with enrolled students", self) - kwargs.pop('force', None) - super().delete(*args, **kwargs) - - def clean(self): - super().clean() - """ - Checking self.pk is checking if this is a creation (as opposed to an update) - We can't possibly have spacetimes at creation time (because it's a foreign-key field), - so we only validate this on updates - """ - if self.pk and not self.spacetimes.exists(): - raise ValidationError("Section must have at least one Spacetime") - - def __str__(self): - return "{course} section ({enrolled}/{cap}, {mentor}, {spacetimes})".format( - course=self.mentor.course.name, - mentor="(no mentor)" if not self.mentor else self.mentor.name, - enrolled=self.current_student_count, - cap=self.capacity, - spacetimes="|".join(map(str, self.spacetimes.all())) - ) - - -def worksheet_path(instance, filename): - # file will be uploaded to MEDIA_ROOT// - course_name = str(instance.resource.course.name).replace(" ", "") - return f'resources/{course_name}/{filename}' - - -class Resource(ValidatingModel): - course = models.ForeignKey(Course, on_delete=models.CASCADE) - week_num = models.PositiveSmallIntegerField() - date = models.DateField() - topics = models.CharField(blank=True, max_length=100) - - class Meta: - ordering = ['week_num'] - - def clean(self): - super().clean() - if self.course.is_restricted: - raise NotImplementedError("Resources currently cannot be associated with a restricted course.") - - -class Link(ValidatingModel): - resource = models.ForeignKey(Resource, on_delete=models.CASCADE) - name = models.CharField(max_length=200) - url = models.URLField(max_length=255) - - -class Worksheet(ValidatingModel): - resource = models.ForeignKey(Resource, on_delete=models.CASCADE) - name = models.CharField(max_length=100) - worksheet_file = models.FileField(blank=True, upload_to=worksheet_path) - solution_file = models.FileField(blank=True, upload_to=worksheet_path) - - -@receiver(models.signals.post_delete, sender=Worksheet) -def auto_delete_file_on_delete(sender, instance, **kwargs): - """ - Deletes file from filesystem when corresponding - `Worksheet` object is deleted. - """ - if instance.worksheet_file: - instance.worksheet_file.delete(save=False) - if instance.solution_file: - instance.solution_file.delete(save=False) - - -@receiver(models.signals.pre_save, sender=Worksheet) -def auto_delete_file_on_change(sender, instance, **kwargs): - """ - Deletes old file from filesystem when corresponding - `Worksheet` object is updated with a new file. - """ - if not instance.pk: - return False - - db_obj = Worksheet.objects.get(pk=instance.pk) - exists = True - try: - old_file = db_obj.worksheet_file - except Worksheet.DoesNotExist: - exists = False - - if exists: - new_file = instance.worksheet_file - if old_file != new_file: - db_obj.worksheet_file.delete(save=False) - - exists = True - try: - old_file = db_obj.solution_file - except Worksheet.DoesNotExist: - exists = False - - if exists: - new_file = instance.solution_file - if old_file != new_file: - db_obj.solution_file.delete(save=False) - - -class Spacetime(ValidatingModel): - SPACE_REDUCE_REGEX = re.compile(r'\s+') - - location = models.CharField(max_length=200) - start_time = models.TimeField() - duration = models.DurationField() - day_of_week = DayOfWeekField() - section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="spacetimes", null=True, blank=True) - - @property - def override(self): - return self._override if (hasattr(self, "_override") and not self._override.is_expired()) else None - - @property - def end_time(self): - # Time does not support addition/subtraction, - # so we have to create a datetime wrapper over start_time to add it to duration - return (datetime.datetime(year=1, day=1, month=1, - hour=self.start_time.hour, - minute=self.start_time.minute, - second=self.start_time.second) - + self.duration).time() - - def day_number(self): - return day_to_number(self.day_of_week) - - def __str__(self): - formatted_time = self.start_time.strftime("%I:%M %p") - num_minutes = int(self.duration.total_seconds() // 60) - return f"{self.location} {self.day_of_week} {formatted_time} for {num_minutes} min" - - def save(self, *args, **kwargs): - self.location = re.sub(self.SPACE_REDUCE_REGEX, ' ', self.location).strip() - super().save(*args, **kwargs) - - -class Override(ValidatingModel): - # related_name='+' means Django does not create the reverse relation - spacetime = models.OneToOneField(Spacetime, on_delete=models.CASCADE, related_name="+") - overriden_spacetime = models.OneToOneField(Spacetime, related_name="_override", on_delete=models.CASCADE) - date = models.DateField() - - def clean(self): - super().clean() - if self.spacetime == self.overriden_spacetime: - raise ValidationError("A spacetime cannot override itself") - if self.spacetime.day_of_week != self.date.strftime("%A"): - raise ValidationError("Day of week of spacetime and day of week of date do not match") - - def is_expired(self): - now = timezone.now().astimezone(timezone.get_default_timezone()) - return self.date < now.date() - - def __str__(self): - return f"Override for {self.overriden_spacetime.section} : {self.spacetime}" - - -class Matcher(ValidatingModel): - course = OneToOneOrNoneField(Course, on_delete=models.CASCADE, blank=True, null=True) - """ - Serialized assignment of mentors to times. - [{mentor: int, slot: int, section: {capacity: int, description: str}}, ...] - """ - assignment = models.JSONField(default=dict, blank=True) - is_open = models.BooleanField(default=False) - - active = models.BooleanField(default=True) - - -class MatcherSlot(ValidatingModel): - matcher = models.ForeignKey(Matcher, on_delete=models.CASCADE) - """ - Serialized times of the form: - [{"day", "startTime", "endTime"}, ...] - Time is in hh:mm 24-hour format - """ - times = models.JSONField() - min_mentors = models.PositiveSmallIntegerField() - max_mentors = models.PositiveSmallIntegerField() - - def clean(self): - super().clean() - if self.min_mentors > self.max_mentors: - raise ValidationError("Min mentors cannot be greater than max mentors") - - class Meta: - unique_together = ("matcher", "times") - - -class MatcherPreference(ValidatingModel): - slot = models.ForeignKey(MatcherSlot, on_delete=models.CASCADE) - mentor = models.ForeignKey(Mentor, on_delete=models.CASCADE) - preference = models.PositiveSmallIntegerField() - - class Meta: - unique_together = ("slot", "mentor") +import datetime +import re +from django.db import models +from django.db.models.fields.related_descriptors import ReverseOneToOneDescriptor +from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.dispatch import receiver +from django.utils import timezone, functional +from rest_framework.serializers import ValidationError +import logging + +logger = logging.getLogger(__name__) + +logger.info = logger.warning + + +class DayOfWeekField(models.Field): + DAYS = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') + + description = "Represents a single day of the week, ordered Monday - Sunday, backed by a Postgres enum" + + def db_type(self, connection): + return 'day_of_week' + + +def day_to_number(day_of_week): + return DayOfWeekField.DAYS.index(day_of_week) + + +def week_bounds(date): + week_start = date - datetime.timedelta(days=date.weekday()) + week_end = week_start + datetime.timedelta(weeks=1) + return week_start, week_end + + +class User(AbstractUser): + priority_enrollment = models.DateTimeField(null=True, blank=True) + + # profile-relevant fields + bio = models.CharField(max_length=1000, blank=True) + pronouns = models.CharField(max_length=50, blank=True) + pronunciation = models.CharField(max_length=50, blank=True) + is_private = models.BooleanField() + + def can_enroll_in_course(self, course, bypass_enrollment_time=False): + # check restricted first + if course.is_restricted and not self.is_whitelisted_for(course): + return False + + is_associated = (self.student_set.filter(active=True, section__mentor__course=course).count() or + self.mentor_set.filter(section__mentor__course=course).count()) + if bypass_enrollment_time: + return not is_associated + else: + if self.priority_enrollment: + now = timezone.now().astimezone(timezone.get_default_timezone()) + is_valid_enrollment_time = self.priority_enrollment < now < course.enrollment_end + else: + is_valid_enrollment_time = course.is_open() + return is_valid_enrollment_time and not is_associated + + def is_whitelisted_for(self, course: "Course"): + return not course.is_restricted or self.whitelist.filter(pk=course.pk).exists() + + class Meta: + indexes = (models.Index(fields=("email",)),) + + +class ValidatingModel(models.Model): + """ + By default, Django models do not validate on save! + This abstract class fixes that insanity + """ + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + class Meta: + abstract = True + + +class ReverseOneToOneOrNoneDescriptor(ReverseOneToOneDescriptor): + def __get__(self, *args, **kwargs): + try: + return super().__get__(*args, **kwargs) + except ObjectDoesNotExist: + return None + + +class OneToOneOrNoneField(models.OneToOneField): + """ + A OneToOneField that returns None if the related object does not exist + """ + related_accessor_class = ReverseOneToOneOrNoneDescriptor + + +class Attendance(ValidatingModel): + class Presence(models.TextChoices): + PRESENT = "PR", "Present" + UNEXCUSED_ABSENCE = "UN", "Unexcused absence" + EXCUSED_ABSENCE = "EX", "Excused absence" + + presence = models.CharField(max_length=2, choices=Presence.choices, blank=True) + student = models.ForeignKey("Student", on_delete=models.CASCADE) + sectionOccurrence = models.ForeignKey("SectionOccurrence", on_delete=models.CASCADE) + + def __str__(self): + return f"{self.sectionOccurrence.date} {self.presence} {self.student.name}" + + @property + def section(self): + return self.student.section + + @property + def week_start(self): + return week_bounds(self.sectionOccurrence.date)[0] + + class Meta: + unique_together = ("sectionOccurrence", "student") + ordering = ("sectionOccurrence",) + # indexes = (models.Index(fields=("date",)),) + + +class SectionOccurrence(ValidatingModel): + """ + SectionOccurrence represents an occurrence of a section and acts as an + intermediate step between Section and Attendance. Now attendances dont + have dates but rather are associated with Section Occurrence. + """ + section = models.ForeignKey("Section", on_delete=models.CASCADE) + date = models.DateField() + word_of_the_day = models.CharField(max_length=50, blank=True) + + def __str__(self): + return f"SectionOccurrence for {self.section} at {self.date}" + + class Meta: + unique_together = ("section", "date") + ordering = ("date",) + + +class Course(ValidatingModel): + name = models.SlugField(max_length=16, unique_for_month="enrollment_start") + title = models.CharField(max_length=100) + valid_until = models.DateField() + section_start = models.DateField() + enrollment_start = models.DateTimeField() + enrollment_end = models.DateTimeField() + permitted_absences = models.PositiveSmallIntegerField() + # time limit for wotd submission; + # section occurrence date + day limit, rounded to EOD + word_of_the_day_limit = models.DurationField(null=True, blank=True) + + is_restricted = models.BooleanField(default=False) + whitelist = models.ManyToManyField("User", blank=True, related_name="whitelist") + + def __str__(self): + return self.name + + def clean(self): + super().clean() + if self.section_start <= self.enrollment_start.date(): + raise ValidationError("section_start must be after enrollment_start") + if self.enrollment_end <= self.enrollment_start: + raise ValidationError("enrollment_end must be after enrollment_start") + if self.valid_until < self.enrollment_end.date(): + raise ValidationError("valid_until must be after enrollment_end") + + # check word of the day limit is in days + if ( + isinstance(self.word_of_the_day_limit, datetime.timedelta) + and self.word_of_the_day_limit.seconds > 0 + ): + raise ValidationError("word of the day limit must be in days") + + def is_open(self): + now = timezone.now().astimezone(timezone.get_default_timezone()) + return self.enrollment_start < now < self.enrollment_end + + +class Profile(ValidatingModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + # all students, mentors, and coords should have a field for course + course = models.ForeignKey(Course, on_delete=models.CASCADE) + + @property + def name(self): + return self.user.get_full_name() + + def __str__(self): + return f"{self.name} ({self.course.name})" + + class Meta: + abstract = True + + +class Student(Profile): + """ + Represents a given "instance" of a student. Every section in which a student enrolls should + have a new Student profile. + """ + section = models.ForeignKey("Section", on_delete=models.CASCADE, related_name="students") + active = models.BooleanField(default=True, help_text="An inactive student is a dropped student.") + banned = models.BooleanField( + default=False, help_text="A banned student cannot enroll in another section for the course they are banned from") + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + """ + Create an attendance for this week for the student if their section hasn't already been held this week + and no attendance for this week already exists. + """ + now = timezone.now().astimezone(timezone.get_default_timezone()) + week_start = week_bounds(now.date())[0] + for spacetime in self.section.spacetimes.all(): + section_day_num = day_to_number(spacetime.day_of_week) + section_already_held = section_day_num < now.weekday() or ( + section_day_num == now.weekday() and spacetime.start_time < now.time()) + course = self.course + if self.active and course.section_start <= now.date() < course.valid_until\ + and not section_already_held and not self.attendance_set.filter(sectionOccurrence__date=week_start+datetime.timedelta(days=section_day_num)).exists(): + if settings.DJANGO_ENV != settings.DEVELOPMENT: + logger.info( + f" SO automatically created for student {self.user.email} in course {course.name} for date {now.date()}") + logger.info( + f" Attendance automatically created for student {self.user.email} in course {course.name} for date {now.date()}") + so_qs = SectionOccurrence.objects.filter( + section=self.section, date=week_start + datetime.timedelta(days=section_day_num)) + if not so_qs.exists(): + so = SectionOccurrence.objects.create( + section=self.section, date=week_start + datetime.timedelta(days=section_day_num)) + else: + so = so_qs.get() + Attendance.objects.create(student=self, sectionOccurrence=so) + + def clean(self): + super().clean() + # ensure consistency with two course fields + if self.section.mentor.course != self.course: + raise ValidationError("Student must be associated with the same course as the section they are in.") + + class Meta: + unique_together = ("user", "section") + + +class Mentor(Profile): + """ + Represents a given "instance" of a mentor. Every section a mentor teaches in every course should + have a new Mentor profile. + """ + + +class Coordinator(Profile): + """ + This profile is used to allow coordinators to acess the admin page. + """ + + def save(self, *args, **kwargs): + self.user.is_staff = True + self.user.save() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.user.get_full_name()} ({self.course.name})" + + class Meta: + unique_together = ("user", "course") + + +class Section(ValidatingModel): + # course = models.ForeignKey(Course, on_delete=models.CASCADE) + capacity = models.PositiveSmallIntegerField() + mentor = OneToOneOrNoneField(Mentor, on_delete=models.CASCADE, blank=True, null=True) + description = models.CharField( + max_length=100, + blank=True, + help_text='A brief note to add some extra information about the section, e.g. "EOP" or ' + '"early start".' + ) + + # @functional.cached_property + # def course(self): + # return self.mentor.course + + @functional.cached_property + def current_student_count(self): + return self.students.filter(active=True).count() + + def delete(self, *args, **kwargs): + if self.current_student_count and not kwargs.get('force'): + raise models.ProtectedError("Cannot delete section with enrolled students", self) + kwargs.pop('force', None) + super().delete(*args, **kwargs) + + def clean(self): + super().clean() + """ + Checking self.pk is checking if this is a creation (as opposed to an update) + We can't possibly have spacetimes at creation time (because it's a foreign-key field), + so we only validate this on updates + """ + if self.pk and not self.spacetimes.exists(): + raise ValidationError("Section must have at least one Spacetime") + + def __str__(self): + return "{course} section ({enrolled}/{cap}, {mentor}, {spacetimes})".format( + course=self.mentor.course.name, + mentor="(no mentor)" if not self.mentor else self.mentor.name, + enrolled=self.current_student_count, + cap=self.capacity, + spacetimes="|".join(map(str, self.spacetimes.all())) + ) + + +def worksheet_path(instance, filename): + # file will be uploaded to MEDIA_ROOT// + course_name = str(instance.resource.course.name).replace(" ", "") + return f'resources/{course_name}/{filename}' + + +class Resource(ValidatingModel): + course = models.ForeignKey(Course, on_delete=models.CASCADE) + week_num = models.PositiveSmallIntegerField() + date = models.DateField() + topics = models.CharField(blank=True, max_length=100) + + class Meta: + ordering = ['week_num'] + + def clean(self): + super().clean() + if self.course.is_restricted: + raise NotImplementedError("Resources currently cannot be associated with a restricted course.") + + +class Link(ValidatingModel): + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + name = models.CharField(max_length=200) + url = models.URLField(max_length=255) + + +class Worksheet(ValidatingModel): + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + name = models.CharField(max_length=100) + worksheet_file = models.FileField(blank=True, upload_to=worksheet_path) + solution_file = models.FileField(blank=True, upload_to=worksheet_path) + + +@receiver(models.signals.post_delete, sender=Worksheet) +def auto_delete_file_on_delete(sender, instance, **kwargs): + """ + Deletes file from filesystem when corresponding + `Worksheet` object is deleted. + """ + if instance.worksheet_file: + instance.worksheet_file.delete(save=False) + if instance.solution_file: + instance.solution_file.delete(save=False) + + +@receiver(models.signals.pre_save, sender=Worksheet) +def auto_delete_file_on_change(sender, instance, **kwargs): + """ + Deletes old file from filesystem when corresponding + `Worksheet` object is updated with a new file. + """ + if not instance.pk: + return False + + db_obj = Worksheet.objects.get(pk=instance.pk) + exists = True + try: + old_file = db_obj.worksheet_file + except Worksheet.DoesNotExist: + exists = False + + if exists: + new_file = instance.worksheet_file + if old_file != new_file: + db_obj.worksheet_file.delete(save=False) + + exists = True + try: + old_file = db_obj.solution_file + except Worksheet.DoesNotExist: + exists = False + + if exists: + new_file = instance.solution_file + if old_file != new_file: + db_obj.solution_file.delete(save=False) + + +class Spacetime(ValidatingModel): + SPACE_REDUCE_REGEX = re.compile(r'\s+') + + location = models.CharField(max_length=200) + start_time = models.TimeField() + duration = models.DurationField() + day_of_week = DayOfWeekField() + section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="spacetimes", null=True, blank=True) + + @property + def override(self): + return self._override if (hasattr(self, "_override") and not self._override.is_expired()) else None + + @property + def end_time(self): + # Time does not support addition/subtraction, + # so we have to create a datetime wrapper over start_time to add it to duration + return (datetime.datetime(year=1, day=1, month=1, + hour=self.start_time.hour, + minute=self.start_time.minute, + second=self.start_time.second) + + self.duration).time() + + def day_number(self): + return day_to_number(self.day_of_week) + + def __str__(self): + formatted_time = self.start_time.strftime("%I:%M %p") + num_minutes = int(self.duration.total_seconds() // 60) + return f"{self.location} {self.day_of_week} {formatted_time} for {num_minutes} min" + + def save(self, *args, **kwargs): + self.location = re.sub(self.SPACE_REDUCE_REGEX, ' ', self.location).strip() + super().save(*args, **kwargs) + + +class Override(ValidatingModel): + # related_name='+' means Django does not create the reverse relation + spacetime = models.OneToOneField(Spacetime, on_delete=models.CASCADE, related_name="+") + overriden_spacetime = models.OneToOneField(Spacetime, related_name="_override", on_delete=models.CASCADE) + date = models.DateField() + + def clean(self): + super().clean() + if self.spacetime == self.overriden_spacetime: + raise ValidationError("A spacetime cannot override itself") + if self.spacetime.day_of_week != self.date.strftime("%A"): + raise ValidationError("Day of week of spacetime and day of week of date do not match") + + def is_expired(self): + now = timezone.now().astimezone(timezone.get_default_timezone()) + return self.date < now.date() + + def __str__(self): + return f"Override for {self.overriden_spacetime.section} : {self.spacetime}" + + +class Matcher(ValidatingModel): + course = OneToOneOrNoneField(Course, on_delete=models.CASCADE, blank=True, null=True) + """ + Serialized assignment of mentors to times. + [{mentor: int, slot: int, section: {capacity: int, description: str}}, ...] + """ + assignment = models.JSONField(default=dict, blank=True) + is_open = models.BooleanField(default=False) + + active = models.BooleanField(default=True) + + +class MatcherSlot(ValidatingModel): + matcher = models.ForeignKey(Matcher, on_delete=models.CASCADE) + """ + Serialized times of the form: + [{"day", "startTime", "endTime"}, ...] + Time is in hh:mm 24-hour format + """ + times = models.JSONField() + min_mentors = models.PositiveSmallIntegerField() + max_mentors = models.PositiveSmallIntegerField() + + def clean(self): + super().clean() + if self.min_mentors > self.max_mentors: + raise ValidationError("Min mentors cannot be greater than max mentors") + + class Meta: + unique_together = ("matcher", "times") + + +class MatcherPreference(ValidatingModel): + slot = models.ForeignKey(MatcherSlot, on_delete=models.CASCADE) + mentor = models.ForeignKey(Mentor, on_delete=models.CASCADE) + preference = models.PositiveSmallIntegerField() + + class Meta: + unique_together = ("slot", "mentor") diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index bcb1636e..d7c447da 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -1,298 +1,300 @@ -from rest_framework import serializers -from enum import Enum -from django.utils import timezone -from .models import Attendance, Course, Link, SectionOccurrence, User, Student, Section, Mentor, Override, Spacetime, Coordinator, DayOfWeekField, Resource, Worksheet, Matcher, MatcherSlot, MatcherPreference - - -class Role(Enum): - COORDINATOR = "COORDINATOR" - STUDENT = "STUDENT" - MENTOR = "MENTOR" - - -def get_profile_role(profile): - for role, klass in zip(Role, (Coordinator, Student, Mentor)): - if isinstance(profile, klass): - return role.value - - -def make_omittable(field_class, omit_key, *args, predicate=None, **kwargs): - """ - Behaves exactly as if the field were defined directly by calling `field_class(*args, **kwargs)`, - except that if `omit_key` is present in the context when the field is serialized and predicate returns True, - the value is omitted and `None` is returned instead. - - Useful for when you want to leave out one or two fields in one view, while including them in - another view, without having to go through the trouble of writing two completely separate serializers. - This is a marked improvement over using a `SerializerMethodField` because this approach still allows - writing to the field to work without any additional machinery. - """ - predicate_provided = predicate is not None - predicate = predicate or (lambda _: True) - - class OmittableField(field_class): - def get_attribute(self, instance): - """ - This is an important performance optimization that prevents us from hitting the DB for an - unconditionally omitted field, as by the time to_representation is called, the DB has already been queried - (because `value` has to come from *somewhere*). - """ - return None if self.context.get(omit_key) and not predicate_provided else super().get_attribute(instance) - - def to_representation(self, value): - return None if self.context.get(omit_key) and predicate(value) else super().to_representation(value) - - return OmittableField(*args, **kwargs) - - -class OverrideReadOnlySerializer(serializers.ModelSerializer): - spacetime = serializers.SerializerMethodField() - date = serializers.DateField(format="%b. %-d") - - def get_spacetime(self, obj): - # Gets around cyclic dependency issue - return SpacetimeSerializer(obj.spacetime, context={**self.context, 'omit_overrides': True}).data - - class Meta: - model = Override - fields = ("spacetime", "date") - read_only_fields = ("spacetime", "date") - - -class SpacetimeSerializer(serializers.ModelSerializer): - - time = serializers.SerializerMethodField() - location = make_omittable(serializers.CharField, 'omit_spacetime_links', - predicate=lambda location: location.startswith('http')) - override = make_omittable(OverrideReadOnlySerializer, 'omit_overrides', read_only=True) - - def get_time(self, obj): - if obj.start_time.strftime("%p") != obj.end_time.strftime("%p"): - return f"{obj.day_of_week} {obj.start_time.strftime('%-I:%M %p')}-{obj.end_time.strftime('%-I:%M %p')}" - return f"{obj.day_of_week} {obj.start_time.strftime('%-I:%M')}-{obj.end_time.strftime('%-I:%M %p')}" - - class Meta: - model = Spacetime - fields = ("start_time", "day_of_week", "time", "location", "id", "duration", "override") - read_only_fields = ("time", "id", "override") - - -class CourseSerializer(serializers.ModelSerializer): - enrollment_open = serializers.SerializerMethodField() - user_can_enroll = serializers.SerializerMethodField() - - def get_enrollment_open(self, obj): - user = self.context.get('request') and self.context.get('request').user - if user and user.priority_enrollment and user.priority_enrollment < obj.enrollment_start: - now = timezone.now().astimezone(timezone.get_default_timezone()) - return user.priority_enrollment < now < obj.enrollment_end - else: - return obj.is_open() - - def get_user_can_enroll(self, obj): - user = self.context.get('request') and self.context.get('request').user - return user and user.can_enroll_in_course(obj) - - class Meta: - model = Course - fields = ("id", "name", "enrollment_start", "enrollment_open", "user_can_enroll", "is_restricted", "word_of_the_day_limit") - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ("id", "email", "first_name", "last_name", "priority_enrollment") - - -class ProfileSerializer(serializers.Serializer): - class VariableSourceCourseField(serializers.Field): - def __init__(self, *args, **kwargs): - self.target = kwargs.pop('target') - super().__init__(self, *args, **kwargs) - - def to_representation(self, value): - return getattr(value.course, self.target) - - id = serializers.IntegerField() - section_id = serializers.IntegerField(source='section.id', required=False) - section_spacetimes = SpacetimeSerializer(source='section.spacetimes', many=True, required=False) - course = VariableSourceCourseField(source='*', target='name', required=False) - course_title = VariableSourceCourseField(source='*', target='title', required=False) - course_id = VariableSourceCourseField(source='*', target='pk', required=False) - role = serializers.SerializerMethodField() - - def get_role(self, obj): - return get_profile_role(obj) - - -class MentorSerializer(serializers.ModelSerializer): - email = make_omittable(serializers.EmailField, 'omit_mentor_emails', source='user.email') - - class Meta: - model = Mentor - fields = ("id", "name", "email", "section") - - -class AttendanceSerializer(serializers.ModelSerializer): - date = serializers.DateField( - source="sectionOccurrence.date", format="%b. %-d, %Y", read_only=True - ) - student_name = serializers.CharField(source="student.name") - student_id = serializers.IntegerField(source="student.id") - student_email = serializers.CharField(source="student.user.email") - word_of_the_day_deadline = serializers.SerializerMethodField() - - def get_word_of_the_day_deadline(self, obj): - """Compute deadline for the word of the day.""" - limit = obj.sectionOccurrence.section.mentor.course.word_of_the_day_limit - if limit is None: - return None - return obj.sectionOccurrence.date + limit - - class Meta: - model = Attendance - fields = ( - "id", - "date", - "presence", - "student_name", - "student_id", - "student_email", - "word_of_the_day_deadline", - ) - extra_kwargs = {"student": {"write_only": True}} - - def update(self, instance, validated_data): - # only update the attendance date - instance.presence = validated_data.get("presence") - instance.save() - return instance - - -class StudentSerializer(serializers.ModelSerializer): - email = serializers.EmailField(source='user.email') - attendances = AttendanceSerializer(source='attendance_set', many=True) - - class Meta: - model = Student - fields = ("id", "name", "email", "attendances", "section") - - -class SectionSerializer(serializers.ModelSerializer): - spacetimes = SpacetimeSerializer(many=True) - num_students_enrolled = serializers.SerializerMethodField() - mentor = MentorSerializer() - course = serializers.CharField(source='mentor.course.name') - course_title = serializers.CharField(source='mentor.course.title') - user_role = serializers.SerializerMethodField() - associated_profile_id = serializers.SerializerMethodField() - course_restricted = serializers.BooleanField(source='mentor.course.is_restricted') - - def get_num_students_enrolled(self, obj): - return obj.num_students_annotation if hasattr(obj, 'num_students_annotation') else obj.current_student_count - - def user_associated_profile(self, obj): - user = self.context.get('request') and self.context.get('request').user - if not user: - return - try: - return obj.students.get(user=user) - except Student.DoesNotExist: - coordinator = obj.mentor.course.coordinator_set.filter(user=user).first() - if coordinator: - return coordinator - if obj.mentor and obj.mentor.user == user: - return obj.mentor - return None # no profile - - def get_user_role(self, obj): - profile = self.user_associated_profile(obj) - if not profile: - return - return get_profile_role(profile) - - def get_associated_profile_id(self, obj): - profile = self.user_associated_profile(obj) - return profile and profile.pk - - class Meta: - model = Section - fields = ("id", "spacetimes", "mentor", "capacity", "associated_profile_id", - "num_students_enrolled", "description", "mentor", "course", "user_role", - "course_title", "course_restricted") - - -class WorksheetSerializer(serializers.ModelSerializer): - class Meta: - model = Worksheet - fields = ['id', 'name', 'resource', 'worksheet_file', 'solution_file'] - - -class LinkSerializer(serializers.ModelSerializer): - class Meta: - model = Link - fields = ['id', 'name', 'resource', 'url'] - - -class ResourceSerializer(serializers.ModelSerializer): - worksheets = WorksheetSerializer(source='worksheet_set', many=True) - links = LinkSerializer(source='link_set', many=True) - - class Meta: - model = Resource - fields = ['id', 'course', 'week_num', 'date', 'topics', 'worksheets', 'links'] - - -class SectionOccurrenceSerializer(serializers.ModelSerializer): - attendances = AttendanceSerializer(source='attendance_set', many=True) - - class Meta: - model = SectionOccurrence - fields = ('id', 'date', 'section', 'attendances') - - -class OverrideSerializer(serializers.ModelSerializer): - location = serializers.CharField(source='spacetime.location') - start_time = serializers.TimeField(source='spacetime.start_time') - date = serializers.DateField() - - def create(self, validated_data): - spacetime = Spacetime.objects.create( - **validated_data['spacetime'], day_of_week=DayOfWeekField.DAYS[validated_data['date'].weekday()], duration=validated_data['overriden_spacetime'].duration) - return Override.objects.create(date=validated_data['date'], overriden_spacetime=validated_data['overriden_spacetime'], - spacetime=spacetime) - - def update(self, instance, validated_data): - instance.date = validated_data['date'] - spacetime_data = validated_data['spacetime'] - instance.spacetime.day_of_week = DayOfWeekField.DAYS[validated_data['date'].weekday()] - instance.spacetime.location = spacetime_data['location'] - instance.spacetime.start_time = spacetime_data['start_time'] - instance.spacetime.duration = instance.overriden_spacetime.duration - instance.spacetime.save() - instance.save() - return instance - - class Meta: - model = Override - fields = ("location", "start_time", "date", "overriden_spacetime") - extra_kwargs = {"overriden_spacetime": {'required': False}} - - -class MatcherSerializer(serializers.ModelSerializer): - class Meta: - model = Matcher - fields = ('id', 'course', 'assignment', 'is_open') - - -class MatcherSlotSerializer(serializers.ModelSerializer): - class Meta: - model = MatcherSlot - fields = ['id', 'matcher', 'times'] - - -class MatcherPreferenceSerializer(serializers.ModelSerializer): - - class Meta: - model = MatcherPreference - fields = ['slot', 'mentor', 'preference'] +from rest_framework import serializers +from enum import Enum +from django.utils import timezone +from .models import Attendance, Course, Link, SectionOccurrence, User, Student, Section, Mentor, Override, Spacetime, Coordinator, DayOfWeekField, Resource, Worksheet, Matcher, MatcherSlot, MatcherPreference + + +class Role(Enum): + COORDINATOR = "COORDINATOR" + STUDENT = "STUDENT" + MENTOR = "MENTOR" + + +def get_profile_role(profile): + for role, klass in zip(Role, (Coordinator, Student, Mentor)): + if isinstance(profile, klass): + return role.value + + +def make_omittable(field_class, omit_key, *args, predicate=None, **kwargs): + """ + Behaves exactly as if the field were defined directly by calling `field_class(*args, **kwargs)`, + except that if `omit_key` is present in the context when the field is serialized and predicate returns True, + the value is omitted and `None` is returned instead. + + Useful for when you want to leave out one or two fields in one view, while including them in + another view, without having to go through the trouble of writing two completely separate serializers. + This is a marked improvement over using a `SerializerMethodField` because this approach still allows + writing to the field to work without any additional machinery. + """ + predicate_provided = predicate is not None + predicate = predicate or (lambda _: True) + + class OmittableField(field_class): + def get_attribute(self, instance): + """ + This is an important performance optimization that prevents us from hitting the DB for an + unconditionally omitted field, as by the time to_representation is called, the DB has already been queried + (because `value` has to come from *somewhere*). + """ + return None if self.context.get(omit_key) and not predicate_provided else super().get_attribute(instance) + + def to_representation(self, value): + return None if self.context.get(omit_key) and predicate(value) else super().to_representation(value) + + return OmittableField(*args, **kwargs) + + +class OverrideReadOnlySerializer(serializers.ModelSerializer): + spacetime = serializers.SerializerMethodField() + date = serializers.DateField(format="%b. %-d") + + def get_spacetime(self, obj): + # Gets around cyclic dependency issue + return SpacetimeSerializer(obj.spacetime, context={**self.context, 'omit_overrides': True}).data + + class Meta: + model = Override + fields = ("spacetime", "date") + read_only_fields = ("spacetime", "date") + + +class SpacetimeSerializer(serializers.ModelSerializer): + + time = serializers.SerializerMethodField() + location = make_omittable(serializers.CharField, 'omit_spacetime_links', + predicate=lambda location: location.startswith('http')) + override = make_omittable(OverrideReadOnlySerializer, 'omit_overrides', read_only=True) + + def get_time(self, obj): + if obj.start_time.strftime("%p") != obj.end_time.strftime("%p"): + return f"{obj.day_of_week} {obj.start_time.strftime('%-I:%M %p')}-{obj.end_time.strftime('%-I:%M %p')}" + return f"{obj.day_of_week} {obj.start_time.strftime('%-I:%M')}-{obj.end_time.strftime('%-I:%M %p')}" + + class Meta: + model = Spacetime + fields = ("start_time", "day_of_week", "time", "location", "id", "duration", "override") + read_only_fields = ("time", "id", "override") + + +class CourseSerializer(serializers.ModelSerializer): + enrollment_open = serializers.SerializerMethodField() + user_can_enroll = serializers.SerializerMethodField() + + def get_enrollment_open(self, obj): + user = self.context.get('request') and self.context.get('request').user + if user and user.priority_enrollment and user.priority_enrollment < obj.enrollment_start: + now = timezone.now().astimezone(timezone.get_default_timezone()) + return user.priority_enrollment < now < obj.enrollment_end + else: + return obj.is_open() + + def get_user_can_enroll(self, obj): + user = self.context.get('request') and self.context.get('request').user + return user and user.can_enroll_in_course(obj) + + class Meta: + model = Course + fields = ("id", "name", "enrollment_start", "enrollment_open", + "user_can_enroll", "is_restricted", "word_of_the_day_limit") + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "email", "first_name", "last_name", "bio", "pronouns", + "pronunciation", "priority_enrollment", "is_private") + + +class ProfileSerializer(serializers.Serializer): + class VariableSourceCourseField(serializers.Field): + def __init__(self, *args, **kwargs): + self.target = kwargs.pop('target') + super().__init__(self, *args, **kwargs) + + def to_representation(self, value): + return getattr(value.course, self.target) + + id = serializers.IntegerField() + section_id = serializers.IntegerField(source='section.id', required=False) + section_spacetimes = SpacetimeSerializer(source='section.spacetimes', many=True, required=False) + course = VariableSourceCourseField(source='*', target='name', required=False) + course_title = VariableSourceCourseField(source='*', target='title', required=False) + course_id = VariableSourceCourseField(source='*', target='pk', required=False) + role = serializers.SerializerMethodField() + + def get_role(self, obj): + return get_profile_role(obj) + + +class MentorSerializer(serializers.ModelSerializer): + email = make_omittable(serializers.EmailField, 'omit_mentor_emails', source='user.email') + + class Meta: + model = Mentor + fields = ("id", "name", "email", "section") + + +class AttendanceSerializer(serializers.ModelSerializer): + date = serializers.DateField( + source="sectionOccurrence.date", format="%b. %-d, %Y", read_only=True + ) + student_name = serializers.CharField(source="student.name") + student_id = serializers.IntegerField(source="student.id") + student_email = serializers.CharField(source="student.user.email") + word_of_the_day_deadline = serializers.SerializerMethodField() + + def get_word_of_the_day_deadline(self, obj): + """Compute deadline for the word of the day.""" + limit = obj.sectionOccurrence.section.mentor.course.word_of_the_day_limit + if limit is None: + return None + return obj.sectionOccurrence.date + limit + + class Meta: + model = Attendance + fields = ( + "id", + "date", + "presence", + "student_name", + "student_id", + "student_email", + "word_of_the_day_deadline", + ) + extra_kwargs = {"student": {"write_only": True}} + + def update(self, instance, validated_data): + # only update the attendance date + instance.presence = validated_data.get("presence") + instance.save() + return instance + + +class StudentSerializer(serializers.ModelSerializer): + email = serializers.EmailField(source='user.email') + attendances = AttendanceSerializer(source='attendance_set', many=True) + + class Meta: + model = Student + fields = ("id", "name", "email", "attendances", "section") + + +class SectionSerializer(serializers.ModelSerializer): + spacetimes = SpacetimeSerializer(many=True) + num_students_enrolled = serializers.SerializerMethodField() + mentor = MentorSerializer() + course = serializers.CharField(source='mentor.course.name') + course_title = serializers.CharField(source='mentor.course.title') + user_role = serializers.SerializerMethodField() + associated_profile_id = serializers.SerializerMethodField() + course_restricted = serializers.BooleanField(source='mentor.course.is_restricted') + + def get_num_students_enrolled(self, obj): + return obj.num_students_annotation if hasattr(obj, 'num_students_annotation') else obj.current_student_count + + def user_associated_profile(self, obj): + user = self.context.get('request') and self.context.get('request').user + if not user: + return + try: + return obj.students.get(user=user) + except Student.DoesNotExist: + coordinator = obj.mentor.course.coordinator_set.filter(user=user).first() + if coordinator: + return coordinator + if obj.mentor and obj.mentor.user == user: + return obj.mentor + return None # no profile + + def get_user_role(self, obj): + profile = self.user_associated_profile(obj) + if not profile: + return + return get_profile_role(profile) + + def get_associated_profile_id(self, obj): + profile = self.user_associated_profile(obj) + return profile and profile.pk + + class Meta: + model = Section + fields = ("id", "spacetimes", "mentor", "capacity", "associated_profile_id", + "num_students_enrolled", "description", "mentor", "course", "user_role", + "course_title", "course_restricted") + + +class WorksheetSerializer(serializers.ModelSerializer): + class Meta: + model = Worksheet + fields = ['id', 'name', 'resource', 'worksheet_file', 'solution_file'] + + +class LinkSerializer(serializers.ModelSerializer): + class Meta: + model = Link + fields = ['id', 'name', 'resource', 'url'] + + +class ResourceSerializer(serializers.ModelSerializer): + worksheets = WorksheetSerializer(source='worksheet_set', many=True) + links = LinkSerializer(source='link_set', many=True) + + class Meta: + model = Resource + fields = ['id', 'course', 'week_num', 'date', 'topics', 'worksheets', 'links'] + + +class SectionOccurrenceSerializer(serializers.ModelSerializer): + attendances = AttendanceSerializer(source='attendance_set', many=True) + + class Meta: + model = SectionOccurrence + fields = ('id', 'date', 'section', 'attendances') + + +class OverrideSerializer(serializers.ModelSerializer): + location = serializers.CharField(source='spacetime.location') + start_time = serializers.TimeField(source='spacetime.start_time') + date = serializers.DateField() + + def create(self, validated_data): + spacetime = Spacetime.objects.create( + **validated_data['spacetime'], day_of_week=DayOfWeekField.DAYS[validated_data['date'].weekday()], duration=validated_data['overriden_spacetime'].duration) + return Override.objects.create(date=validated_data['date'], overriden_spacetime=validated_data['overriden_spacetime'], + spacetime=spacetime) + + def update(self, instance, validated_data): + instance.date = validated_data['date'] + spacetime_data = validated_data['spacetime'] + instance.spacetime.day_of_week = DayOfWeekField.DAYS[validated_data['date'].weekday()] + instance.spacetime.location = spacetime_data['location'] + instance.spacetime.start_time = spacetime_data['start_time'] + instance.spacetime.duration = instance.overriden_spacetime.duration + instance.spacetime.save() + instance.save() + return instance + + class Meta: + model = Override + fields = ("location", "start_time", "date", "overriden_spacetime") + extra_kwargs = {"overriden_spacetime": {'required': False}} + + +class MatcherSerializer(serializers.ModelSerializer): + class Meta: + model = Matcher + fields = ('id', 'course', 'assignment', 'is_open') + + +class MatcherSlotSerializer(serializers.ModelSerializer): + class Meta: + model = MatcherSlot + fields = ['id', 'matcher', 'times'] + + +class MatcherPreferenceSerializer(serializers.ModelSerializer): + + class Meta: + model = MatcherPreference + fields = ['slot', 'mentor', 'preference'] diff --git a/csm_web/scheduler/tests/models/test_user.py b/csm_web/scheduler/tests/models/test_user.py index f14141af..0563ef26 100644 --- a/csm_web/scheduler/tests/models/test_user.py +++ b/csm_web/scheduler/tests/models/test_user.py @@ -5,6 +5,7 @@ @pytest.mark.django_db def test_create_user(): + # test a user with default information email = "test@berkeley.edu" username = "test" user, created = User.objects.get_or_create(email=email, username=username) @@ -13,3 +14,28 @@ def test_create_user(): assert user.username == username assert User.objects.count() == 1 assert User.objects.get(email=email).username == username + assert User.objects.get(email=email).bio == None + + # test a user with a comeplete profile + email = "example@berkeley.edu" + username = "example" + bio = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus congue neque et massa hendrerit varius. Suspendisse feugiat est ipsum, sed tincidunt lacus feugiat quis. Vivamus dictum condimentum lorem eu volutpat. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse pulvinar tortor at libero iaculis, sit amet fringilla purus bibendum. Praesent id magna vel metus suscipit volutpat. Cras tincidunt ante sed pretium pretium. Nullam pretium cursus sapien ut gravida. Aenean tristique iaculis ex, laoreet congue leo vestibulum vitae. Nam metus orci, tincidunt id risus id, luctus porttitor arcu. Suspendisse ut mattis quam. Proin congue metus quis lectus consectetur tempus. Nulla sit amet metus sed enim vestibulum elementum et a enim." + pronouns = "___/____" + pronounciation = "ex-am-ple" + is_private = False + example, created = User.objects.get_or_create( + email=email, username=username, bio=bio, pronouns=pronouns, pronounciation=pronounciation, is_private=is_private) + assert created + assert example.email == email + assert example.username == username + assert User.objects.count() == 2 + assert example.username == username + assert example.bio == bio + assert example.pronouns == pronouns + assert example.pronunciation == pronounciation + assert not example.is_private + +# TODO test getting a user +# TODO test updating a user's information +# TODO features: sanitizing input (frontend job?), any characters django can't handle, how long should each filed be +# TODO edge cases: can't think of any now diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py index b997222f..a1b15f7b 100644 --- a/csm_web/scheduler/urls.py +++ b/csm_web/scheduler/urls.py @@ -1,26 +1,27 @@ -from django.urls import path -from rest_framework.routers import DefaultRouter - -from . import views - -router = DefaultRouter() -router.register(r"courses", views.CourseViewSet, basename="course") -router.register(r"sections", views.SectionViewSet, basename="section") -router.register(r"students", views.StudentViewSet, basename="student") -router.register(r"profiles", views.ProfileViewSet, basename="profile") -router.register(r"spacetimes", views.SpacetimeViewSet, basename="spacetime") -router.register(r"users", views.UserViewSet, basename="user") -router.register(r"resources", views.ResourceViewSet, basename="resource") - -urlpatterns = router.urls - -urlpatterns += [ - path("userinfo/", views.userinfo, name="userinfo"), - path("matcher/active/", views.matcher.active), - path("matcher//slots/", views.matcher.slots), - path("matcher//preferences/", views.matcher.preferences), - path("matcher//assignment/", views.matcher.assignment), - path("matcher//mentors/", views.matcher.mentors), - path("matcher//configure/", views.matcher.configure), - path("matcher//create/", views.matcher.create), -] +from django.urls import path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register(r"courses", views.CourseViewSet, basename="course") +router.register(r"sections", views.SectionViewSet, basename="section") +router.register(r"students", views.StudentViewSet, basename="student") +router.register(r"profiles", views.ProfileViewSet, basename="profile") +router.register(r"spacetimes", views.SpacetimeViewSet, basename="spacetime") +router.register(r"users", views.UserViewSet, basename="user") +router.register(r"resources", views.ResourceViewSet, basename="resource") + +urlpatterns = router.urls + +urlpatterns += [ + path("userinfo/", views.userinfo, name="userinfo"), + path("userprofile/", views.userprofile, name="userprofile"), + path("matcher/active/", views.matcher.active), + path("matcher//slots/", views.matcher.slots), + path("matcher//preferences/", views.matcher.preferences), + path("matcher//assignment/", views.matcher.assignment), + path("matcher//mentors/", views.matcher.mentors), + path("matcher//configure/", views.matcher.configure), + path("matcher//create/", views.matcher.create), +] diff --git a/csm_web/scheduler/views/user.py b/csm_web/scheduler/views/user.py index aeef9440..29aedd1d 100644 --- a/csm_web/scheduler/views/user.py +++ b/csm_web/scheduler/views/user.py @@ -23,12 +23,38 @@ def list(self, request): return Response(self.queryset.order_by("email").values_list("email", flat=True)) -@api_view(["GET"]) +@api_view(["GET", "PUT"]) def userinfo(request): """ Get user info for request user + Put user info for request user + """ + if request.method == "GET": + serializer = UserSerializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) + elif request.method == "PUT": + user = request.user + bio = request.data.get("bio") + pronunciation = request.data.get("pronunciation") + pronouns = request.data.get("pronouns") + is_private = request.data.get("is_private") + if bio is not None: + user.bio = bio + if pronunciation is not None: + user.pronunciation = pronunciation + if pronouns is not None: + user.pronouns = pronouns + if is_private is not None: + user.is_private = is_private + user.save() + return Response(status=status.HTTP_200_OK) + - TODO: perhaps replace this with a viewset when we establish profiles +@api_view(["GET"]) +def userprofile(request, user_id): + """ + Get the user information (for profiles) of other people (user_id) """ - serializer = UserSerializer(request.user) + user = User.objects.get(id=user_id) + serializer = UserSerializer(user) return Response(serializer.data, status=status.HTTP_200_OK)