merging old commit with current master
[oweals/karmaworld.git] / karmaworld / apps / courses / models.py
index 7c82802696f17a4302721d8ae3993e17f2333a92..6ef836c2d566068a155695a12303b1a3ae1d1e1f 100644 (file)
@@ -9,14 +9,32 @@
     Courses have a manytoone relation to schools.
 """
 import datetime
+import reversion
 
 from django.db import models
-from django.template import defaultfilters
+from django.utils.text import slugify
+from django.core.urlresolvers import reverse
 from karmaworld.settings.manual_unique_together import auto_add_check_unique_together
+from ajax_select import LookupChannel
+from ajax_select_cascade import DependentLookupChannel
+from ajax_select_cascade import register_channel_name
+
+from karmaworld.utils.ajax_selects import register_channel_name
+
+
+class SchoolManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, usde_id):
+        """
+        Return a School defined by USDE number.
+        """
+        return self.get(usde_id=usde_id)
 
 
 class School(models.Model):
     """ A grouping that contains many courses """
+    objects     = SchoolManager()
+
     name        = models.CharField(max_length=255)
     slug        = models.SlugField(max_length=150, null=True)
     location    = models.CharField(max_length=255, blank=True, null=True)
@@ -24,23 +42,31 @@ class School(models.Model):
     # Facebook keeps a unique identifier for all schools
     facebook_id = models.BigIntegerField(blank=True, null=True)
     # United States Department of Education institution_id
-    usde_id     = models.BigIntegerField(blank=True, null=True)
+    usde_id     = models.BigIntegerField(blank=True, null=True, unique=True)
     file_count  = models.IntegerField(default=0)
     priority    = models.BooleanField(default=0)
     alias       = models.CharField(max_length=255, null=True, blank=True)
+    hashtag     = models.CharField(max_length=16, null=True, blank=True, unique=True, help_text='School abbreviation without #')
 
     class Meta:
         """ Sort School by file_count descending, name abc=> """
         ordering = ['-file_count','-priority', 'name']
 
+    def natural_key(self):
+        """
+        A School is uniquely defined by USDE number.
+
+        Name should be unique, but there are some dupes in the DB.
+        """
+        return (self.usde_id,)
 
     def __unicode__(self):
-        return self.name
+        return u'School {0}: {1}'.format(self.usde_id, self.name)
 
     def save(self, *args, **kwargs):
         """ Save school and generate a slug if one doesn't exist """
         if not self.slug:
-            self.slug = defaultfilters.slugify(self.name)
+            self.slug = slugify(unicode(self.name))
         super(School, self).save(*args, **kwargs)
 
     @staticmethod
@@ -48,34 +74,197 @@ class School(models.Model):
         return ("name__icontains",)
 
     def update_note_count(self):
-        """ Update the School.file_count by summing the 
+        """ Update the School.file_count by summing the
             contained course.file_count
         """
         self.file_count = sum([course.file_count for course in self.course_set.all()])
         self.save()
 
-    def update_related_note_count(self):
-        """ Runs the update_note_count function on all related course
-            objects, then generates the self.file_count
+
+@register_channel_name('school')
+class SchoolLookup(LookupChannel):
+    """
+    Handles AJAX lookups against the school model's name and value fields.
+    """
+    model = School
+
+    def get_query(self, q, request):
+        """ Search against both name and alias. """
+        query = models.Q(name__icontains=q) | models.Q(alias__icontains=q)
+        return School.objects.filter(query)
+
+    def check_auth(self, request):
+        """ Allow anonymous access. """
+        # By default, Lookups require request.is_staff. Don't require anything!
+        pass
+
+
+class DepartmentManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, name, school):
+        """
+        Return a Department defined by its name and school.
+        """
+        return self.get(name=name, school=school)
+
+
+class Department(models.Model):
+    """ Department within a School. """
+    objects     = DepartmentManager()
+
+    name        = models.CharField(max_length=255)
+    school      = models.ForeignKey(School) # Should this be optional ever?
+    slug        = models.SlugField(max_length=150, null=True)
+    url         = models.URLField(max_length=511, blank=True, null=True)
+
+    class Meta:
+        """
+        The same department name might exist across schools, but only once
+        per school.
+        """
+        unique_together = ('name', 'school',)
+
+    def __unicode__(self):
+        return u'Department: {0} at {1}'.format(self.name, unicode(self.school))
+
+    def natural_key(self):
+        """
+        A Department is uniquely defined by its school and name.
         """
-        for course in self.course_set.all():
-            course.update_note_count()
-        self.update_note_count()
+        return (self.name, self.school.natural_key())
+    # Requires School to be dumped first
+    natural_key.dependencies = ['courses.school']
+
+    def save(self, *args, **kwargs):
+        """ Save department and generate a slug if one doesn't exist """
+        if not self.slug:
+            self.slug = slugify(unicode(self.name))
+        super(Department, self).save(*args, **kwargs)
+
+
+@register_channel_name('dept_given_school')
+class DeptGivenSchoolLookup(DependentLookupChannel):
+    """
+    Handles AJAX lookups against the department model's name field given a
+    school.
+    """
+    model = Department
+
+    def get_dependent_query(self, q, request, dependency):
+        """ Search against department name given a school. """
+        if dependency:
+            return Department.objects.filter(name__icontains=q,
+                                             school__id=dependency)
+        else:
+            return []
+
+    def check_auth(self, request):
+        """ Allow anonymous access. """
+        # By default, Lookups require request.is_staff. Don't require anything!
+        pass
+
+
+class ProfessorManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, name, email):
+        """
+        Return a Professor defined by name and email address.
+        """
+        return self.get(name=name,email=email)
+
+
+class Professor(models.Model):
+    """
+    Track professors for courses.
+    """
+    objects = ProfessorManager()
+
+    name = models.CharField(max_length=255)
+    email = models.EmailField(blank=True, null=True)
+
+    class Meta:
+        """
+        email should be unique, but some professors have no email address
+        in the database. For those cases, the name must be appended for
+        uniqueness.
+        """
+        unique_together = ('name', 'email',)
+
+    def __unicode__(self):
+        return u'Professor: {0} ({1})'.format(self.name, self.email)
+
+    def natural_key(self):
+        """
+        A Professor is uniquely defined by his/her name and email.
+        """
+        return (self.name,self.email)
+
+
+class ProfessorAffiliationManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, prof, dept):
+        """
+        Return a ProfessorAffiliation defined by prof and department.
+        """
+        return self.get(professor=prof,department=dept)
+
+
+class ProfessorAffiliation(models.Model):
+    """
+    Track professors for departments. (many-to-many)
+    """
+    objects    = ProfessorAffiliationManager()
+
+    professor  = models.ForeignKey(Professor)
+    department = models.ForeignKey(Department)
+
+    def __unicode__(self):
+        return u'Professor {0} working for {1}'.format(unicode(self.professor), unicode(self.department))
+
+    class Meta:
+        """
+        Many-to-many across both professor and department.
+        However, (prof, dept) as a tuple should only appear once.
+        """
+        unique_together = ('professor', 'department',)
+
+    def natural_key(self):
+        """
+        A ProfessorAffiliation is uniquely defined by the prof and department
+        """
+        return (self.professor.natural_key(), self.department.natural_key())
+    # Requires dependencies to be dumped first
+    natural_key.dependencies = ['courses.professor','courses.department']
+
+
+class CourseManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, name, dept):
+        """
+        Return a Course defined by name and department.
+        """
+        return self.get(name=name,department=dept)
 
 
 class Course(models.Model):
     """ First class object that contains many notes.Note objects """
+    objects     = CourseManager()
+
     # Core metadata
-    name        = models.CharField(max_length=255)
+    name        = models.CharField(max_length=255, verbose_name="Course:")
     slug        = models.SlugField(max_length=150, null=True)
-    school      = models.ForeignKey(School) # Should this be optional ever?
+    # department should remove nullable when school gets yoinked
+    department  = models.ForeignKey(Department, blank=True, null=True)
+    # school is an appendix: the kind that gets swollen and should be removed
+    # (vistigial)
+    school      = models.ForeignKey(School, null=True, blank=True)
     file_count  = models.IntegerField(default=0)
 
     desc        = models.TextField(max_length=511, blank=True, null=True)
-    url         = models.URLField(max_length=511, blank=True, null=True)
-    academic_year   = models.IntegerField(blank=True, null=True, 
-                        default=datetime.datetime.now().year)
+    url         = models.URLField(max_length=511, blank=True, null=True,
+                                  verbose_name="Course URL:")
 
+    # instructor_* is vestigial, replaced by Professor+ProfessorTaught models.
     instructor_name     = models.CharField(max_length=255, blank=True, null=True)
     instructor_email    = models.EmailField(blank=True, null=True)
 
@@ -83,31 +272,45 @@ class Course(models.Model):
 
     created_at      = models.DateTimeField(auto_now_add=True)
 
+    # Number of times this course has been flagged as abusive/spam.
+    flags           = models.IntegerField(default=0,null=False)
 
     class Meta:
         ordering = ['-file_count', 'school', 'name']
-        unique_together = ('school', 'name', 'instructor_name')
+        unique_together = ('name', 'department')
+        unique_together = ('name', 'school')
         verbose_name = 'course'
         verbose_name_plural = 'courses'
 
     def __unicode__(self):
-        return u"{0}: {1}".format(self.name, self.school)
+        return u"Course {0} in {1}".format(self.name, unicode(self.department))
+
+    def natural_key(self):
+        """
+        A Course is uniquely defined by its name and the department it is in.
+        """
+        return (self.name, self.department.natural_key())
+    # Requires dependencies to be dumped first
+    natural_key.dependencies = ['courses.department']
 
     def get_absolute_url(self):
-        """ return url based on school slug and self slug """
-        return u"/{0}/{1}".format(self.school.slug, self.slug)
+        """ return url based on urls.py definition. """
+        return reverse('course_detail', kwargs={'slug':self.slug})
 
     def save(self, *args, **kwargs):
         """ Save school and generate a slug if one doesn't exist """
         super(Course, self).save(*args, **kwargs) # generate a self.id
         if not self.slug:
-            self.slug = defaultfilters.slugify("%s %s" % (self.name, self.id))
-            self.save() # Save the slug
+            self.set_slug()
 
     def get_updated_at_string(self):
         """ return the formatted style for datetime strings """
         return self.updated_at.strftime("%I%p // %a %b %d %Y")
 
+    def set_slug(self):
+        self.slug = slugify(u"%s %s" % (self.name, self.id))
+        self.save() # Save the slug
+
     @staticmethod
     def autocomplete_search_fields():
         return ("name__icontains",)
@@ -117,7 +320,56 @@ class Course(models.Model):
         self.file_count = self.note_set.count()
         self.save()
 
+    def get_popularity(self):
+        """ Aggregate popularity of notes contained within. """
+        # Run an efficient GROUP BY aggregation within the database.
+        # It returns {'fieldname': #}, where fieldname is set in the left hand
+        # side of the aggregate kwarg. Call the field x and retrieve the dict
+        # value using that key.
+        # The value might be None, return zero in that case with shortcut logic.
+        return self.note_set.aggregate(x=models.Sum('thanks'))['x'] or 0
+
+reversion.register(Course)
+
+class ProfessorTaughtManager(models.Manager):
+    """ Handle restoring data. """
+    def get_by_natural_key(self, prof, course):
+        """
+        Return a ProfessorTaught defined by professor and course.
+        """
+        return self.get(professor=prof, course=course)
+
+
+class ProfessorTaught(models.Model):
+    """
+    Track professors teaching courses. (many-to-many)
+    """
+    objects   = ProfessorTaughtManager()
+
+    professor = models.ForeignKey(Professor)
+    course    = models.ForeignKey(Course)
+
+    def __unicode__(self):
+        return u'Professor {0} taught {1}'.format(unicode(self.professor), unicode(self.course))
+
+    class Meta:
+        # many-to-many across both fields,
+        # but (prof, course) as a tuple should only appear once.
+        unique_together = ('professor', 'course',)
+
+    def natural_key(self):
+        """
+        A ProfessorTaught is uniquely defined by the prof and course.
+        """
+        return (self.professor.natural_key(), self.course.natural_key())
+    # Requires dependencies to be dumped first
+    natural_key.dependencies = ['courses.professor','courses.course']
+
+
 # Enforce unique constraints even when we're using a database like
 # SQLite that doesn't understand them
 auto_add_check_unique_together(Course)
-
+auto_add_check_unique_together(Department)
+auto_add_check_unique_together(Professor)
+auto_add_check_unique_together(ProfessorAffiliation)
+auto_add_check_unique_together(ProfessorTaught)