Replace explicit Prof/Course model with ManyToMany relationship.
[oweals/karmaworld.git] / karmaworld / apps / courses / models.py
1 #!/usr/bin/env python
2 # -*- coding:utf8 -*-
3 # Copyright (C) 2012  FinalsClub Foundation
4
5 """
6     Models for the courses django app.
7     Handles courses, and their related models
8     Courses are the first class object, they contain notes.
9     Courses have a manytoone relation to schools.
10 """
11 import datetime
12 import reversion
13
14 from django.db import models
15 from django.utils.text import slugify
16 from django.core.urlresolvers import reverse
17 from karmaworld.settings.manual_unique_together import auto_add_check_unique_together
18 from ajax_select import LookupChannel
19 from ajax_select_cascade import DependentLookupChannel
20 from ajax_select_cascade import register_channel_name
21
22
23 class AnonLookupChannel(LookupChannel):
24     def check_auth(self, request):
25         """ Allow anonymous access. """
26         # By default, Lookups require request.is_staff. Don't require anything!
27         pass
28
29
30 class FieldLookupChannel(AnonLookupChannel):
31     def get_query(self, q, request):
32         """
33         Case insensitive contain search against the given field.
34         Returns model objects with matching field.
35         """
36         kwargs = { str(self.field_lookup) + '__icontains': q }
37         return self.model.objects.filter(**kwargs)
38
39
40 class SchoolManager(models.Manager):
41     """ Handle restoring data. """
42     def get_by_natural_key(self, usde_id):
43         """
44         Return a School defined by USDE number.
45         """
46         return self.get(usde_id=usde_id)
47
48
49 class School(models.Model):
50     """ A grouping that contains many courses """
51     objects     = SchoolManager()
52
53     name        = models.CharField(max_length=255)
54     slug        = models.SlugField(max_length=150, null=True)
55     location    = models.CharField(max_length=255, blank=True, null=True)
56     url         = models.URLField(max_length=511, blank=True)
57     # Facebook keeps a unique identifier for all schools
58     facebook_id = models.BigIntegerField(blank=True, null=True)
59     # United States Department of Education institution_id
60     usde_id     = models.BigIntegerField(blank=True, null=True, unique=True)
61     file_count  = models.IntegerField(default=0)
62     priority    = models.BooleanField(default=0)
63     alias       = models.CharField(max_length=255, null=True, blank=True)
64     hashtag     = models.CharField(max_length=16, null=True, blank=True, unique=True, help_text='School abbreviation without #')
65
66     class Meta:
67         """ Sort School by file_count descending, name abc=> """
68         ordering = ['-file_count','-priority', 'name']
69
70     def natural_key(self):
71         """
72         A School is uniquely defined by USDE number.
73
74         Name should be unique, but there are some dupes in the DB.
75         """
76         return (self.usde_id,)
77
78     def __unicode__(self):
79         return u'School {0}: {1}'.format(self.usde_id, self.name)
80
81     def save(self, *args, **kwargs):
82         """ Save school and generate a slug if one doesn't exist """
83         if not self.slug:
84             self.slug = slugify(unicode(self.name))
85         super(School, self).save(*args, **kwargs)
86
87     @staticmethod
88     def autocomplete_search_fields():
89         return ("name__icontains",)
90
91     def update_note_count(self):
92         """ Update the School.file_count by summing the
93             contained course.file_count
94         """
95         self.file_count = sum([course.file_count for course in self.course_set.all()])
96         self.save()
97
98
99 @register_channel_name('school_object_by_name')
100 class SchoolLookup(AnonLookupChannel):
101     """
102     Handles AJAX lookups against the school model's name and alias fields.
103     """
104     model = School
105
106     def get_query(self, q, request):
107         """ Search against both name and alias. """
108         query = models.Q(name__icontains=q) | models.Q(alias__icontains=q)
109         return self.model.objects.filter(query)
110
111
112 class DepartmentManager(models.Manager):
113     """ Handle restoring data. """
114     def get_by_natural_key(self, name, school):
115         """
116         Return a Department defined by its name and school.
117         """
118         return self.get(name=name, school=school)
119
120
121 class Department(models.Model):
122     """ Department within a School. """
123     objects     = DepartmentManager()
124
125     name        = models.CharField(max_length=255, verbose_name="Department name")
126     school      = models.ForeignKey(School) # Should this be optional ever?
127     slug        = models.SlugField(max_length=150, null=True)
128     url         = models.URLField(max_length=511, blank=True, null=True)
129
130     class Meta:
131         """
132         The same department name might exist across schools, but only once
133         per school.
134         """
135         unique_together = ('name', 'school',)
136
137     def __unicode__(self):
138         return u'Department: {0} at {1}'.format(self.name, unicode(self.school))
139
140     def natural_key(self):
141         """
142         A Department is uniquely defined by its school and name.
143         """
144         return (self.name, self.school.natural_key())
145     # Requires School to be dumped first
146     natural_key.dependencies = ['courses.school']
147
148     def save(self, *args, **kwargs):
149         """ Save department and generate a slug if one doesn't exist """
150         if not self.slug:
151             self.slug = slugify(unicode(self.name))
152         super(Department, self).save(*args, **kwargs)
153
154
155 @register_channel_name('dept_object_by_name_given_school')
156 class DeptGivenSchoolLookup(DependentLookupChannel, AnonLookupChannel):
157     """
158     Handles AJAX lookups against the department model's name field given a
159     school.
160     """
161     model = Department
162
163     def get_dependent_query(self, q, request, dependency):
164         """ Search against department name given a school. """
165         if dependency:
166             return Department.objects.filter(name__icontains=q,
167                                              school__id=dependency)
168         else:
169             # If no dependency is submit, return nothing.
170             return []
171
172
173 class ProfessorManager(models.Manager):
174     """ Handle restoring data. """
175     def get_by_natural_key(self, name, email):
176         """
177         Return a Professor defined by name and email address.
178         """
179         return self.get(name=name,email=email)
180
181
182 class Professor(models.Model):
183     """
184     Track professors for courses.
185     """
186     objects = ProfessorManager()
187
188     name  = models.CharField(max_length=255, verbose_name="Professor's name")
189     email = models.EmailField(blank=True, null=True,
190         verbose_name="Professor's Email")
191
192     class Meta:
193         """
194         email should be unique, but some professors have no email address
195         in the database. For those cases, the name must be appended for
196         uniqueness.
197         """
198         unique_together = ('name', 'email',)
199
200     def __unicode__(self):
201         return u'Professor: {0} ({1})'.format(self.name, self.email)
202
203     def natural_key(self):
204         """
205         A Professor is uniquely defined by his/her name and email.
206         """
207         return (self.name,self.email)
208
209
210 @register_channel_name('professor_object_by_name')
211 class ProfessorLookup(FieldLookupChannel):
212     """
213     Handles AJAX lookups against the professor model's name field.
214     """
215     model = Professor
216     field_lookup = 'name'
217
218
219 @register_channel_name('professor_object_by_email')
220 class ProfessorEmailLookup(FieldLookupChannel):
221     """
222     Handles AJAX lookups against the professor model's email field.
223     """
224     model = Professor
225     field_lookup = 'email'
226
227
228 class CourseManager(models.Manager):
229     """ Handle restoring data. """
230     def get_by_natural_key(self, name, dept):
231         """
232         Return a Course defined by name and department.
233         """
234         return self.get(name=name,department=dept)
235
236
237 class Course(models.Model):
238     """ First class object that contains many notes.Note objects """
239     objects     = CourseManager()
240
241     # Core metadata
242     name        = models.CharField(max_length=255, verbose_name="Course name")
243     slug        = models.SlugField(max_length=150, null=True)
244     # department should remove nullable when school gets yoinked
245     department  = models.ForeignKey(Department, blank=True, null=True)
246     # school is an appendix: the kind that gets swollen and should be removed
247     # (vistigial)
248     school      = models.ForeignKey(School, null=True, blank=True)
249     file_count  = models.IntegerField(default=0)
250
251     desc        = models.TextField(max_length=511, blank=True, null=True)
252     url         = models.URLField(max_length=511, blank=True, null=True,
253                                   verbose_name="Course URL")
254
255     # professor should remove nullable when school instructor_* yoinked
256     professor = models.ManyToManyField(Professor, blank=True, null=True)
257     # instructor_* is vestigial
258     instructor_name     = models.CharField(max_length=255, blank=True, null=True)
259     instructor_email    = models.EmailField(blank=True, null=True)
260
261     updated_at      = models.DateTimeField(default=datetime.datetime.utcnow)
262
263     created_at      = models.DateTimeField(auto_now_add=True)
264
265     # Number of times this course has been flagged as abusive/spam.
266     flags           = models.IntegerField(default=0,null=False)
267
268     class Meta:
269         ordering = ['-file_count', 'school', 'name']
270         unique_together = ('name', 'department')
271         unique_together = ('name', 'school')
272         verbose_name = 'course'
273         verbose_name_plural = 'courses'
274
275     def __unicode__(self):
276         return u"Course {0} in {1}".format(self.name, unicode(self.department))
277
278     def natural_key(self):
279         """
280         A Course is uniquely defined by its name and the department it is in.
281         """
282         return (self.name, self.department.natural_key())
283     # Requires dependencies to be dumped first
284     natural_key.dependencies = ['courses.department']
285
286     def get_absolute_url(self):
287         """ return url based on urls.py definition. """
288         return reverse('course_detail', kwargs={'slug':self.slug})
289
290     def save(self, *args, **kwargs):
291         """ Save school and generate a slug if one doesn't exist """
292         super(Course, self).save(*args, **kwargs) # generate a self.id
293         if not self.slug:
294             self.set_slug()
295
296     def get_updated_at_string(self):
297         """ return the formatted style for datetime strings """
298         return self.updated_at.strftime("%I%p // %a %b %d %Y")
299
300     def set_slug(self):
301         self.slug = slugify(u"%s %s" % (self.name, self.id))
302         self.save() # Save the slug
303
304     @staticmethod
305     def autocomplete_search_fields():
306         return ("name__icontains",)
307
308     def update_note_count(self):
309         """ Update self.file_count by summing the note_set """
310         self.file_count = self.note_set.count()
311         self.save()
312
313     def get_popularity(self):
314         """ Aggregate popularity of notes contained within. """
315         # Run an efficient GROUP BY aggregation within the database.
316         # It returns {'fieldname': #}, where fieldname is set in the left hand
317         # side of the aggregate kwarg. Call the field x and retrieve the dict
318         # value using that key.
319         # The value might be None, return zero in that case with shortcut logic.
320         return self.note_set.aggregate(x=models.Sum('thanks'))['x'] or 0
321
322     def get_prof_names(self):
323         """ Comma separated list of professor names. """
324         # old style: just use the given name
325         if self.instructor_name:
326             return str(self.instructor_name)
327         # Run through all associated professors and concatenate their names.
328         return ','.join(self.professor.values_list('name', flat=True))
329
330     def get_prof_emails(self):
331         """ Comma separated list of professor emails. """
332         # old style: just use the given name
333         if self.instructor_email:
334             return str(self.instructor_email)
335         # Run through all associated professors and concatenate their names.
336         return ','.join(self.professor.values_list('email', flat=True))
337
338 reversion.register(Course)
339
340
341 @register_channel_name('course_name_by_name')
342 class CourseNameLookup(FieldLookupChannel):
343     """
344     Handles AJAX lookups against the course model's name field.
345     Returns just the matching field values.
346     """
347     model = Course
348     field_lookup = 'name'
349
350     def get_query(self, q, request):
351         """ Return only the list of name fields. """
352         # Find the matching objects.
353         results = super(CourseNameLookup, self).get_query(q, request)
354         # Only return the name field, not the object.
355         return results.values_list(self.field_lookup, flat=True)
356
357
358 # Enforce unique constraints even when we're using a database like
359 # SQLite that doesn't understand them
360 auto_add_check_unique_together(Course)
361 auto_add_check_unique_together(School)
362 auto_add_check_unique_together(Department)
363 auto_add_check_unique_together(Professor)