13b39d7241be6f25c457473cdcc20cead9add9c6
[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 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         # find all courses without a department
96         course_list = list(self.course_set.all())
97         # find all courses with a department
98         for department in self.department_set.all():
99             for course in department.course_set.all():
100                 course_list.append(course)
101         self.file_count = sum([course.file_count for course in course_list])
102         self.save()
103
104 @register_channel_name('school_object_by_name')
105 class SchoolLookup(AnonLookupChannel):
106     """
107     Handles AJAX lookups against the school model's name and alias fields.
108     """
109     model = School
110
111     def get_query(self, q, request):
112         """ Search against both name and alias. """
113         query = models.Q(name__icontains=q) | models.Q(alias__icontains=q)
114         return self.model.objects.filter(query)
115
116
117 class DepartmentManager(models.Manager):
118     """ Handle restoring data. """
119     def get_by_natural_key(self, name, school):
120         """
121         Return a Department defined by its name and school.
122         """
123         return self.get(name=name, school=school)
124
125
126 class Department(models.Model):
127     """ Department within a School. """
128     objects     = DepartmentManager()
129
130     name        = models.CharField(max_length=255, verbose_name="Department name")
131     school      = models.ForeignKey(School) # Should this be optional ever?
132     slug        = models.SlugField(max_length=150, null=True)
133     url         = models.URLField(max_length=511, blank=True, null=True)
134
135     class Meta:
136         """
137         The same department name might exist across schools, but only once
138         per school.
139         """
140         unique_together = ('name', 'school',)
141
142     def __unicode__(self):
143         return self.name
144
145     def natural_key(self):
146         """
147         A Department is uniquely defined by its school and name.
148         """
149         return (self.name, self.school.natural_key())
150     # Requires School to be dumped first
151     natural_key.dependencies = ['courses.school']
152
153     def save(self, *args, **kwargs):
154         """ Save department and generate a slug if one doesn't exist """
155         if not self.slug:
156             self.slug = slugify(unicode(self.name))
157         super(Department, self).save(*args, **kwargs)
158
159
160 @register_channel_name('dept_object_by_name_given_school')
161 class DeptGivenSchoolLookup(DependentLookupChannel, AnonLookupChannel):
162     """
163     Handles AJAX lookups against the department model's name field given a
164     school.
165     """
166     model = Department
167
168     def get_dependent_query(self, q, request, dependency):
169         """ Search against department name given a school. """
170         if dependency:
171             return Department.objects.filter(name__icontains=q,
172                                              school__id=dependency)
173         else:
174             # If no dependency is submit, return nothing.
175             return []
176
177
178 class ProfessorManager(models.Manager):
179     """ Handle restoring data. """
180     def get_by_natural_key(self, name, email):
181         """
182         Return a Professor defined by name and email address.
183         """
184         return self.get(name=name, email=email)
185
186
187 class Professor(models.Model):
188     """
189     Track professors for courses.
190     """
191     objects = ProfessorManager()
192
193     name  = models.CharField(max_length=255, verbose_name="Professor's name")
194     email = models.EmailField(blank=True, null=True,
195                               verbose_name="Professor's Email")
196
197     class Meta:
198         """
199         email should be unique, but some professors have no email address
200         in the database. For those cases, the name must be appended for
201         uniqueness.
202         """
203         unique_together = ('name', 'email',)
204
205     def __unicode__(self):
206         return self.name
207
208     def natural_key(self):
209         """
210         A Professor is uniquely defined by his/her name and email.
211         """
212         return (self.name,self.email)
213
214
215 @register_channel_name('professor_object_by_name')
216 class ProfessorLookup(FieldLookupChannel):
217     """
218     Handles AJAX lookups against the professor model's name field.
219     """
220     model = Professor
221     field_lookup = 'name'
222
223
224 @register_channel_name('professor_object_by_email')
225 class ProfessorEmailLookup(FieldLookupChannel):
226     """
227     Handles AJAX lookups against the professor model's email field.
228     """
229     model = Professor
230     field_lookup = 'email'
231
232
233 class CourseManager(models.Manager):
234     """ Handle restoring data. """
235     def get_by_natural_key(self, name, dept):
236         """
237         Return a Course defined by name and department.
238         """
239         return self.get(name=name,department=dept)
240
241
242 class Course(models.Model):
243     """ First class object that contains many notes.Note objects """
244     objects     = CourseManager()
245
246     # Core metadata
247     name        = models.CharField(max_length=255, verbose_name="Course name")
248     slug        = models.SlugField(max_length=150, null=True)
249     # department should remove nullable when school gets yoinked
250     department  = models.ForeignKey(Department, blank=True, null=True)
251     # school is an appendix: the kind that gets swollen and should be removed
252     # (vistigial)
253     school      = models.ForeignKey(School, null=True, blank=True)
254     file_count  = models.IntegerField(default=0)
255     thank_count = models.IntegerField(default=0)
256
257     desc        = models.TextField(max_length=511, blank=True, null=True)
258     url         = models.URLField(max_length=511, blank=True, null=True,
259                                   verbose_name="Course URL")
260
261     # professor should remove nullable when school instructor_* yoinked
262     professor = models.ManyToManyField(Professor, blank=True, null=True)
263     # instructor_* is vestigial
264     instructor_name     = models.CharField(max_length=255, blank=True, null=True)
265     instructor_email    = models.EmailField(blank=True, null=True)
266
267     updated_at      = models.DateTimeField(default=datetime.datetime.utcnow)
268
269     created_at      = models.DateTimeField(auto_now_add=True)
270
271     # Number of times this course has been flagged as abusive/spam.
272     flags           = models.IntegerField(default=0,null=False)
273
274     class Meta:
275         ordering = ['-file_count', 'school', 'name']
276         unique_together = ('name', 'department')
277         unique_together = ('name', 'school')
278         verbose_name = 'course'
279         verbose_name_plural = 'courses'
280
281     def __unicode__(self):
282         return u"Course {0} in {1}".format(self.name, unicode(self.department))
283
284     def natural_key(self):
285         """
286         A Course is uniquely defined by its name and the department it is in.
287         """
288         return (self.name, self.department.natural_key())
289     # Requires dependencies to be dumped first
290     natural_key.dependencies = ['courses.department']
291
292     def get_absolute_url(self):
293         """ return url based on urls.py definition. """
294         return reverse('course_detail', kwargs={'slug':self.slug})
295
296     def save(self, *args, **kwargs):
297         """ Save school and generate a slug if one doesn't exist """
298         super(Course, self).save(*args, **kwargs) # generate a self.id
299         if not self.slug:
300             self.set_slug()
301
302     def get_updated_at_string(self):
303         """ return the formatted style for datetime strings """
304         return self.updated_at.strftime("%I%p // %a %b %d %Y")
305
306     def set_slug(self):
307         self.slug = slugify(u"%s %s" % (self.name, self.id))
308         self.save() # Save the slug
309
310     @staticmethod
311     def autocomplete_search_fields():
312         return ("name__icontains",)
313
314     def update_note_count(self):
315         """ Update self.file_count by summing the note_set """
316         self.file_count = self.note_set.count()
317         self.save()
318
319
320     def update_thank_count(self):
321         """ Update the thank_count by summing the note_set
322         """
323         self.thank_count = sum([note.thanks for note in self.note_set.all()])
324         self.save()
325
326
327     def get_prof_names(self):
328         """ Comma separated list of professor names. """
329         # old style: just use the given name
330         if self.instructor_name:
331             return str(self.instructor_name)
332         # Run through all associated professors and concatenate their names.
333         return ','.join(self.professor.values_list('name', flat=True))
334
335     def get_prof_emails(self):
336         """ Comma separated list of professor emails. """
337         # old style: just use the given name
338         if self.instructor_email:
339             return str(self.instructor_email)
340         # Run through all associated professors and concatenate their names.
341         return ','.join(self.professor.values_list('email', flat=True))
342
343 reversion.register(Course)
344
345
346 @register_channel_name('course_name_by_name')
347 class CourseNameLookup(FieldLookupChannel):
348     """
349     Handles AJAX lookups against the course model's name field.
350     Returns just the matching field values.
351     """
352     model = Course
353     field_lookup = 'name'
354
355     def get_query(self, q, request):
356         """ Return only the list of name fields. """
357         # Find the matching objects.
358         results = super(CourseNameLookup, self).get_query(q, request)
359         # Only return the name field, not the object.
360         return results.values_list(self.field_lookup, flat=True)
361
362
363 # Enforce unique constraints even when we're using a database like
364 # SQLite that doesn't understand them
365 auto_add_check_unique_together(Course)
366 auto_add_check_unique_together(School)
367 auto_add_check_unique_together(Department)
368 auto_add_check_unique_together(Professor)