# Copyright (C) 2017 Semester.ly Technologies, LLC
#
# Semester.ly is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Semester.ly is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
from datetime import datetime
import json
import httplib2
from django.core.urlresolvers import reverse
from django.db.models import Q, Count
from django.forms.models import model_to_dict
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render_to_response, render
from django.template import RequestContext
from django.views.decorators.csrf import csrf_exempt
from googleapiclient import discovery
from hashids import Hashids
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from authpipe.utils import check_student_token
from analytics.models import CalendarExport
from courses.serializers import CourseSerializer
from student.models import Student, Reaction, RegistrationToken, PersonalEvent, PersonalTimetable
from student.utils import next_weekday, get_classmates_from_course_id, get_student_tts
from timetable.models import Semester, Course, Section
from timetable.serializers import DisplayTimetableSerializer
from helpers.mixins import ValidateSubdomainMixin, RedirectToSignupMixin
from helpers.decorators import validate_subdomain
from semesterly.settings import get_secret
DAY_MAP = {
'M': 'mo',
'T': 'tu',
'W': 'we',
'R': 'th',
'F': 'fr',
'S': 'sa',
'U': 'su'
}
hashids = Hashids(salt=get_secret('HASHING_SALT'))
[docs]def get_friend_count_from_course_id(school, student, course_id, semester):
"""
Computes the number of friends a user has in a given course for a given semester.
Ignores whether or not those friends have social courses enabled. Never exposes
those user's names or infromation. This count is used purely to upsell user's to
enable social courses.
"""
return PersonalTimetable.objects.filter(student__in=student.friends.all(),
courses__id__exact=course_id) \
.filter(Q(semester=semester)).distinct('student').count()
[docs]def create_unsubscribe_link(student):
""" Generates a unsubscribe link which directs to the student unsubscribe view. """
token = student.get_token()
return reverse('student.views.unsubscribe', kwargs={'id': student.id, 'token': token})
[docs]def unsubscribe(request, student_id, token):
"""
If the student matches the token and the tokens is valid ,
unsubscribes user from emails marking student.emails_enabled
to false. Redirects to index.
"""
student = Student.objects.get(id=student_id)
if student and check_student_token(student, token):
# Link is valid
student.emails_enabled = False
student.save()
return render(request, 'unsubscribe.html')
# Link is invalid. Redirect to homepage.
return HttpResponseRedirect("/")
@csrf_exempt
@validate_subdomain
[docs]def log_ical_export(request):
"""
Logs that a calendar was exported on the frotnend and indicates
it was downloaded rather than exported to Google calendar.
"""
try:
student = Student.objects.get(user=request.user)
except BaseException:
student = None
school = request.subdomain
analytic = CalendarExport.objects.create(
student=student,
school=school,
is_google_calendar=False
)
analytic.save()
return HttpResponse(json.dumps({}), content_type="application/json")
[docs]def accept_tos(request):
"""
Accepts the terms of services for a user, saving the :obj:`datetime` the
terms were accepted.
"""
student = Student.objects.get(user=request.user)
student.time_accepted_tos = datetime.today()
student.save()
return HttpResponse(status=204)
[docs]class UserView(RedirectToSignupMixin, APIView):
""" Handles the accessing and mutating of user information and preferences. """
[docs] def get(self, request):
"""
Renders the user profile/stats page which indicates all of a student's
reviews of courses, what social they have connected, whether notificaitons
are enabled, etc.
"""
student = Student.objects.get(user=request.user)
reactions = Reaction.objects.filter(student=student).values('title').annotate(
count=Count('title'))
if student.user.social_auth.filter(provider='google-oauth2').exists():
has_google = True
else:
has_google = False
if student.user.social_auth.filter(provider='facebook').exists():
img_url = 'https://graph.facebook.com/' + \
student.fbook_uid + '/picture?width=700&height=700'
has_facebook = True
else:
img_url = student.img_url.replace('sz=50', 'sz=700')
has_facebook = False
has_notifications_enabled = RegistrationToken.objects.filter(
student=student).exists()
context = {
'name': student.user,
'major': student.major,
'class': student.class_year,
'student': student,
'total': 0,
'img_url': img_url,
'hasGoogle': has_google,
'hasFacebook': has_facebook,
'notifications': has_notifications_enabled
}
for r in reactions:
context[r['title']] = r['count']
for r in Reaction.REACTION_CHOICES:
if r[0] not in context:
context[r[0]] = 0
context['total'] += context[r[0]]
return render_to_response("profile.html", context,
context_instance=RequestContext(request))
[docs] def patch(self, request):
"""
Updates a user settings to match the corresponding values passed in the
request body. (e.g. social_courses, class_year, major)
"""
student = get_object_or_404(Student, user=request.user)
settings = 'social_offerings social_courses social_all major class_year ' \
'emails_enabled'.split()
for setting in settings:
default_val = getattr(student, setting)
new_val = request.data.get(setting, default_val)
setattr(student, setting, new_val)
student.save()
return Response(status=status.HTTP_204_NO_CONTENT)
[docs] def delete(self, request):
""" Delete this user and all of its data """
request.user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
[docs]class ClassmateView(ValidateSubdomainMixin, RedirectToSignupMixin, APIView):
"""
Handles the computation of classmates for a given course, timetable, or simply
the count of all classmates for a given timetable.
"""
[docs] def get(self, request, sem_name, year):
"""
Returns:
**If the query parameter 'count' is present**
Information regarding the number of friends only::
{
"id": Course with the most friends,
"count": The maximum # of friends in a course,
"total_count": the total # in all classes on timetable,
}
**If the query parameter course_ids is present** a list of dictionaries representing past classmates and current classmates. These are students who the authenticated user is friends
with and who has social courses enabled.::
[{
"course_id":6137,
"past_classmates":[...],
"classmates":[...]
}, ...]
**Otherwise** a list of friends and non-friends alike who have social_all enabled to
be dispalyed in the "find-friends" modal. Sorted by the number courses
the authenticated user shares.::
[{
"name": "...",
"is_friend": Whether or not the user is current user's friend,
"profile_url": link to FB profile,
"shared_courses": [...],
"peer": Info about the user,
}, ...]
"""
if request.query_params.get('count'):
school = request.subdomain
student = Student.objects.get(user=request.user)
course_ids = map(int, request.query_params.getlist('course_ids[]'))
semester, _ = Semester.objects.get_or_create(
name=sem_name, year=year)
total_count = 0
count = 0
most_friend_course_id = -1
for course_id in course_ids:
temp_count = get_friend_count_from_course_id(
school, student, course_id, semester)
if temp_count > count:
count = temp_count
most_friend_course_id = course_id
total_count += temp_count
data = {
"id": most_friend_course_id,
"count": count,
"total_count": total_count}
return Response(data, status=status.HTTP_200_OK)
elif request.query_params.getlist('course_ids[]'):
school = request.subdomain
student = Student.objects.get(user=request.user)
course_ids = map(int, request.query_params.getlist('course_ids[]'))
semester, _ = Semester.objects.get_or_create(
name=sem_name, year=year)
# user opted in to sharing courses
course_to_classmates = {}
if student.social_courses:
friends = student.friends.filter(social_courses=True)
for course_id in course_ids:
course_to_classmates[course_id] = \
get_classmates_from_course_id(school, student, course_id, semester,
friends=friends)
return Response(course_to_classmates, status=status.HTTP_200_OK)
else:
school = request.subdomain
student = Student.objects.get(user=request.user)
semester, _ = Semester.objects.get_or_create(
name=sem_name, year=year)
current_tt = student.personaltimetable_set.filter(school=school,
semester=semester).order_by(
'last_updated').last()
if current_tt is None:
return Response([], status=status.HTTP_200_OK)
current_tt_courses = current_tt.courses.all()
# The most recent TT per student with social enabled that has
# courses in common with input student
matching_tts = PersonalTimetable.objects.filter(student__social_all=True,
courses__id__in=current_tt_courses,
semester=semester) \
.exclude(student=student) \
.order_by('student', 'last_updated') \
.distinct('student')
friends = []
for matching_tt in matching_tts:
friend = matching_tt.student
sections_in_common = matching_tt.sections.all() & current_tt.sections.all()
courses_in_common = matching_tt.courses.all() & current_tt_courses
shared_courses = []
for course in courses_in_common:
shared_courses.append({
'course': model_to_dict(course,
exclude=['unstopped_description', 'description',
'credits']),
# is there a section for this course that is in both timetables?
'in_section': (sections_in_common & course.section_set.all()).exists()
})
friends.append({
'peer': model_to_dict(friend, exclude=['user', 'id', 'fbook_uid', 'friends']),
'is_friend': student.friends.filter(id=friend.id).exists(),
'shared_courses': shared_courses,
'profile_url': 'https://www.facebook.com/' + friend.fbook_uid,
'name': friend.user.first_name + ' ' + friend.user.last_name,
'large_img': 'https://graph.facebook.com/' + friend.fbook_uid +
'/picture?width=700&height=700'
})
friends.sort(key=lambda friend: len(friend['shared_courses']), reverse=True)
return Response(friends, status=status.HTTP_200_OK)
[docs]class GCalView(RedirectToSignupMixin, APIView):
"""
Handles interactions with the Google Calendar API V3 for pulling
and/or sending calendars and calendar events.
"""
[docs] def post(self, request):
"""
Takes the timetable in request.body and creates a weekly
recurring event on Google calendar for each slot in a given week.
Names the Google Calendar "Semester.ly Schedule" if unnamed, otherwise
"[Timetable Name] - Semester.ly".
"""
student = Student.objects.get(user=request.user)
tt = request.data['timetable']
credentials = student.get_google_credentials() # assumes is not None
http = credentials.authorize(httplib2.Http(timeout=100000000))
service = discovery.build('calendar', 'v3', http=http)
school = request.subdomain
tt_name = tt.get('name')
if not tt_name or "Untitled Schedule" in tt_name or len(tt_name) == 0:
tt_name = "Semester.ly Schedule"
else:
tt_name += " - Semester.ly"
# create calendar
calendar = {'summary': tt_name, 'timeZone': 'America/New_York'}
created_calendar = service.calendars().insert(body=calendar).execute()
semester_name = request.data['semester']['name']
semester_year = int(request.data['semester']['year'])
if semester_name == 'Fall':
# ignore year, year is set to current year
sem_start = datetime(semester_year, 8, 30, 17, 0, 0)
sem_end = datetime(semester_year, 12, 20, 17, 0, 0)
else:
# ignore year, year is set to current year
sem_start = datetime(semester_year, 1, 30, 17, 0, 0)
sem_end = datetime(semester_year, 5, 5, 17, 0, 0)
# add events
for slot in tt['slots']:
course = slot['course']
section = slot['section']
for offering in slot['offerings']:
start = next_weekday(sem_start, offering['day'])
start = start.replace(hour=int(offering['time_start'].split(':')[0]),
minute=int(offering['time_start'].split(':')[1]))
end = next_weekday(sem_start, offering['day'])
end = end.replace(hour=int(offering['time_end'].split(':')[0]),
minute=int(offering['time_end'].split(':')[1]))
until = next_weekday(sem_end, offering['day'])
description = course.get('description', '')
instructors = 'Taught by: ' + section['instructors'] + '\n' if len(
section.get('instructors', '')) > 0 else ''
res = {
'summary': course['name'] + " " + course['code'] + section['meeting_section'],
'location': offering['location'],
'description': course['code'] + section['meeting_section'] + '\n' + instructors +
description + '\n\n' + 'Created by Semester.ly',
'start': {
'dateTime': start.strftime("%Y-%m-%dT%H:%M:%S"),
'timeZone': 'America/New_York',
},
'end': {
'dateTime': end.strftime("%Y-%m-%dT%H:%M:%S"),
'timeZone': 'America/New_York',
},
'recurrence': [
'RRULE:FREQ=WEEKLY;UNTIL=' + until.strftime("%Y%m%dT%H%M%SZ") + ';BYDAY=' +
DAY_MAP[offering['day']]
],
}
service.events().insert(
calendarId=created_calendar['id'],
body=res).execute()
analytic = CalendarExport.objects.create(
student=student,
school=school,
is_google_calendar=True
)
analytic.save()
return HttpResponse(json.dumps({}), content_type="application/json")
[docs]class ReactionView(ValidateSubdomainMixin, RedirectToSignupMixin, APIView):
"""
Manages the creation of Reactions to courses.
"""
[docs] def post(self, request):
"""
Create a Reaction for the given course id, with the given title matching
one of the possible emojis. If already present, remove that reaction.
"""
cid = request.data['cid']
title = request.data['title']
student = get_object_or_404(Student, user=request.user)
course = Course.objects.get(id=cid)
if course.reaction_set.filter(title=title, student=student).exists():
reaction = course.reaction_set.get(title=title, student=student)
course.reaction_set.remove(reaction)
reaction.delete()
else:
reaction = Reaction(student=student, title=title)
reaction.save()
course.reaction_set.add(reaction)
course.save()
response = {'reactions': course.get_reactions(student=student)}
return Response(response, status=status.HTTP_200_OK)