├── common ├── __init__.py ├── tests.py ├── apps.py ├── static │ └── common │ │ ├── img │ │ └── combined_img.png │ │ ├── js │ │ └── index.js │ │ └── css │ │ ├── auth.css │ │ └── shared.css ├── templates │ └── common │ │ ├── decline.html │ │ ├── upload_road.html │ │ ├── login_success.html │ │ ├── login_fail.html │ │ ├── dev_login.html │ │ ├── docs │ │ ├── base.html │ │ ├── overview.html │ │ ├── recommender.html │ │ ├── sidebar.html │ │ ├── auth.html │ │ ├── requirements.html │ │ └── sync.html │ │ ├── signup.html │ │ ├── client_approval.html │ │ ├── auth_base.html │ │ ├── index.html │ │ └── base.html ├── admin.py ├── urls.py ├── token_gen.py ├── oauth_client.py └── decorators.py ├── sync ├── __init__.py ├── tests.py ├── apps.py ├── admin.py ├── urls.py ├── models.py └── views.py ├── analytics ├── __init__.py ├── admin.py ├── apps.py ├── static │ └── analytics │ │ ├── css │ │ └── styles.css │ │ └── js │ │ └── analytics.js ├── urls.py ├── request_counter.py └── models.py ├── catalog ├── __init__.py ├── admin.py ├── apps.py └── urls.py ├── fireroad ├── __init__.py ├── wsgi.py ├── settings_prod.py ├── settings_dev.py ├── urls.py └── settings.py ├── middleware ├── __init__.py └── cors.py ├── recommend ├── __init__.py ├── tests.py ├── apps.py ├── admin.py ├── urls.py ├── models.py └── views.py ├── courseupdater ├── __init__.py ├── tests.py ├── apps.py ├── sem-spring-2018 │ ├── delta-3.txt │ ├── delta-1.txt │ └── delta-2.txt ├── admin.py ├── requirements │ └── delta-1.txt ├── sem-fall-2017 │ └── delta-1.txt ├── templates │ └── courseupdater │ │ ├── update_error.html │ │ ├── update_success.html │ │ ├── start_update.html │ │ ├── corrections.html │ │ ├── review_update.html │ │ ├── update_progress.html │ │ └── edit_correction.html ├── static │ └── courseupdater │ │ ├── css │ │ └── style.css │ │ └── js │ │ └── corrections.js ├── urls.py └── models.py ├── requirements ├── __init__.py ├── apps.py ├── templates │ └── requirements │ │ ├── success.html │ │ ├── review.html │ │ ├── base.html │ │ ├── review_all.html │ │ └── edit.html ├── admin.py ├── static │ └── requirements │ │ ├── js │ │ └── edit.js │ │ └── css │ │ ├── editor.css │ │ ├── req_preview.css │ │ └── nav.css ├── urls.py ├── diff.py └── views.py ├── catalog_parse ├── __init__.py ├── utils │ ├── __init__.py │ ├── parse_equivalences.py │ ├── parse_evaluations.py │ └── parse_schedule.py ├── delta_gen.py └── consensus_catalog.py ├── manage.py ├── license.txt ├── setup_catalog.sh ├── .gitignore ├── setup.sh ├── readme.md ├── data └── readme.md ├── privacy_policy.md └── update_catalog.py /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sync/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /analytics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /catalog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fireroad/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /recommend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /courseupdater/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /sync/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /recommend/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /courseupdater/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /sync/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SyncConfig(AppConfig): 5 | name = 'sync' 6 | -------------------------------------------------------------------------------- /common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommonConfig(AppConfig): 5 | name = 'common' 6 | -------------------------------------------------------------------------------- /recommend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RecommendConfig(AppConfig): 5 | name = 'recommend' 6 | -------------------------------------------------------------------------------- /common/static/common/img/combined_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipb/fireroad-server/HEAD/common/static/common/img/combined_img.png -------------------------------------------------------------------------------- /courseupdater/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CourseupdaterConfig(AppConfig): 5 | name = 'courseupdater' 6 | -------------------------------------------------------------------------------- /common/static/common/js/index.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function(){ 2 | $('.sidenav').sidenav(); 3 | $(".dropdown-trigger").dropdown(); 4 | }); 5 | -------------------------------------------------------------------------------- /catalog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | admin.site.register(Course) 7 | -------------------------------------------------------------------------------- /analytics/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | admin.site.register(RequestCount) 7 | -------------------------------------------------------------------------------- /catalog_parse/__init__.py: -------------------------------------------------------------------------------- 1 | from .catalog_parser import parse 2 | from .consensus_catalog import build_consensus 3 | from .delta_gen import make_delta, commit_delta 4 | -------------------------------------------------------------------------------- /catalog/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class CatalogConfig(AppConfig): 7 | name = 'catalog' 8 | -------------------------------------------------------------------------------- /analytics/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class AnalyticsConfig(AppConfig): 7 | name = 'analytics' 8 | -------------------------------------------------------------------------------- /courseupdater/sem-spring-2018/delta-3.txt: -------------------------------------------------------------------------------- 1 | spring#,#2018 2 | 3 3 | courses 4 | condensed_2 5 | condensed_3 6 | condensed_1 7 | condensed_0 8 | 21G 9 | 5 10 | auto_condensed 11 | -------------------------------------------------------------------------------- /requirements/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class RequirementsConfig(AppConfig): 7 | name = 'requirements' 8 | -------------------------------------------------------------------------------- /recommend/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | admin.site.register(Rating) 7 | admin.site.register(Recommendation) 8 | -------------------------------------------------------------------------------- /courseupdater/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import * 3 | 4 | # Register your models here. 5 | admin.site.register(CatalogUpdate) 6 | admin.site.register(CatalogCorrection) 7 | -------------------------------------------------------------------------------- /requirements/templates/requirements/success.html: -------------------------------------------------------------------------------- 1 | {% extends "requirements/base.html" %} 2 | {% block content %} 3 |
4 | Your submission has been sent. Thanks for your contribution! 5 |
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /recommend/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | from django.views.generic import TemplateView 6 | 7 | urlpatterns = [ 8 | url('rate/', views.rate, name='rate'), 9 | url('get/', views.get, name='get') 10 | ] 11 | -------------------------------------------------------------------------------- /sync/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | admin.site.register(Road) 7 | admin.site.register(Schedule) 8 | admin.site.register(RoadBackup) 9 | admin.site.register(ScheduleBackup) 10 | -------------------------------------------------------------------------------- /common/templates/common/decline.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block pagebody %} 5 |
6 |

If you change your mind, go to the Settings page from the Explore tab.

7 |
8 | 9 | {% endblock %} 10 | 11 | -------------------------------------------------------------------------------- /catalog_parse/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .catalog_constants import * 2 | from .parse_prereqs import handle_prereq, handle_coreq 3 | from .parse_schedule import * 4 | from .parse_evaluations import * 5 | from .parse_equivalences import * 6 | from .course_nlp import write_related_and_features 7 | -------------------------------------------------------------------------------- /requirements/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | admin.site.register(RequirementsList) 7 | admin.site.register(EditRequest) 8 | admin.site.register(RequirementsStatement) 9 | admin.site.register(Deployment) 10 | -------------------------------------------------------------------------------- /courseupdater/requirements/delta-1.txt: -------------------------------------------------------------------------------- 1 | 2 | 1 3 | girs 4 | major1 5 | major2 6 | major2a 7 | major2oe 8 | major3 9 | major3a 10 | major3c 11 | major4 12 | major4b 13 | major5-7 14 | major5 15 | major6-1 16 | major6-7 17 | major9 18 | major16-ENG 19 | major16 20 | minor6 21 | minor9 22 | minor21m 23 | minorJapanese 24 | minorRussian 25 | -------------------------------------------------------------------------------- /middleware/cors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Small middleware to allow cross-origin resource sharing. Should only be enabled 3 | in a local development environment. 4 | """ 5 | 6 | class CorsMiddleware(object): 7 | def process_response(self, req, resp): 8 | resp["Access-Control-Allow-Origin"] = "*" 9 | resp["Access-Control-Allow-Headers"] = "*" 10 | return resp 11 | -------------------------------------------------------------------------------- /common/templates/common/upload_road.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | {% csrf_token %} 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /catalog/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'lookup/(?P[A-z0-9.]+)', views.lookup, name='lookup'), 7 | url(r'search/(?P[^?]+)', views.search, name='search'), 8 | url(r'dept/(?P[A-z0-9.]+)', views.department, name='department'), 9 | url(r'all', views.list_all, name='list_all') 10 | ] 11 | -------------------------------------------------------------------------------- /common/templates/common/login_success.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block title %} 5 | Success 6 | 7 | {% endblock %} 8 | 9 | {% block pagebody %} 10 |
11 |

Login successful, syncing your account...

12 |
13 | 14 | {% endblock %} 15 | 16 | -------------------------------------------------------------------------------- /common/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import * 5 | 6 | # The approved_users field creates some issues when trying to update APIClients, 7 | # so just remove it entirely 8 | class APIClientAdmin(admin.ModelAdmin): 9 | exclude = ('approved_users',) 10 | 11 | admin.site.register(Student) 12 | admin.site.register(RedirectURL) 13 | admin.site.register(APIClient, APIClientAdmin) -------------------------------------------------------------------------------- /analytics/static/analytics/css/styles.css: -------------------------------------------------------------------------------- 1 | .chart-card { 2 | padding: 8px 16px 16px 16px; 3 | } 4 | 5 | @media (min-width: 767px) { 6 | .analytics-body { 7 | padding: 12px 30px 30px 30px; 8 | } 9 | } 10 | 11 | @media (max-width: 767px) { 12 | .analytics-body { 13 | padding: 12px 12px 36px 12px; 14 | } 15 | } 16 | 17 | .floating-indicator { 18 | position: absolute; 19 | margin: auto; 20 | left: 0; 21 | right: 0; 22 | top: 0; 23 | bottom: 0; 24 | } 25 | -------------------------------------------------------------------------------- /sync/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url('sync_road/', views.sync_road, name='sync_road'), 7 | url('delete_road/', views.delete_road, name='delete_road'), 8 | url('roads/', views.roads, name='roads'), 9 | url('sync_schedule/', views.sync_schedule, name='sync_schedule'), 10 | url('delete_schedule/', views.delete_schedule, name='delete_schedule'), 11 | url('schedules/', views.schedules, name='schedules') 12 | ] 13 | -------------------------------------------------------------------------------- /common/templates/common/login_fail.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block pagebody %} 5 |
6 |

Login failed: {{ message }}

7 |
8 |
9 | Try Again 10 | Not Now 11 |
12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /courseupdater/sem-spring-2018/delta-1.txt: -------------------------------------------------------------------------------- 1 | spring#,#2018 2 | 1 3 | 1 4 | 2 5 | 3 6 | 4 7 | 5 8 | 6 9 | 7 10 | 8 11 | 9 12 | 10 13 | 11 14 | 12 15 | 14 16 | 15 17 | 16 18 | 17 19 | 18 20 | 20 21 | 21 22 | 21A 23 | 21G 24 | 21H 25 | 21L 26 | 21M 27 | 21W 28 | 22 29 | 24 30 | AS 31 | CC 32 | CMS 33 | condensed_0 34 | condensed_1 35 | condensed_2 36 | condensed_3 37 | departments 38 | enrollment 39 | CSB 40 | EC 41 | EM 42 | ES 43 | HST 44 | IDS 45 | MAS 46 | MS 47 | NS 48 | related 49 | SCM 50 | SP 51 | STS 52 | SWE 53 | WGS 54 | -------------------------------------------------------------------------------- /courseupdater/sem-spring-2018/delta-2.txt: -------------------------------------------------------------------------------- 1 | spring#,#2018 2 | 2 3 | 1 4 | 2 5 | 3 6 | 4 7 | 5 8 | 6 9 | 7 10 | 8 11 | 9 12 | 10 13 | 11 14 | 12 15 | 14 16 | 15 17 | 16 18 | 17 19 | 18 20 | 20 21 | 21 22 | 21A 23 | 21G 24 | 21H 25 | 21L 26 | 21M 27 | 21W 28 | 22 29 | 24 30 | AS 31 | CC 32 | CMS 33 | condensed_0 34 | condensed_1 35 | condensed_2 36 | condensed_3 37 | departments 38 | enrollment 39 | CSB 40 | EC 41 | EM 42 | ES 43 | HST 44 | IDS 45 | MAS 46 | MS 47 | NS 48 | related 49 | SCM 50 | SP 51 | STS 52 | SWE 53 | WGS 54 | -------------------------------------------------------------------------------- /courseupdater/sem-fall-2017/delta-1.txt: -------------------------------------------------------------------------------- 1 | fall#,#2017 2 | 1 3 | 1 4 | 2 5 | 3 6 | 4 7 | 5 8 | 6 9 | 7 10 | 8 11 | 9 12 | 10 13 | 11 14 | 12 15 | 14 16 | 15 17 | 16 18 | 17 19 | 18 20 | 20 21 | 21 22 | 21A 23 | 21G 24 | 21H 25 | 21L 26 | 21M 27 | 21W 28 | 22 29 | 24 30 | AS 31 | CC 32 | CMS 33 | condensed_0 34 | condensed_1 35 | condensed_2 36 | condensed_3 37 | condensed 38 | departments 39 | enrollment 40 | CSB 41 | EC 42 | EM 43 | ES 44 | HST 45 | IDS 46 | MAS 47 | MS 48 | NS 49 | related 50 | SCM 51 | SP 52 | STS 53 | SWE 54 | WGS 55 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/update_error.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Update 5 | 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 |
10 | There was an error updating the catalog: {{ error }} 11 |
12 | Try Again 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /common/templates/common/dev_login.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block pagebody %} 5 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /common/static/common/css/auth.css: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 480px) { 2 | #par { 3 | width: 60%; 4 | margin: auto; 5 | } 6 | } 7 | @media screen and (max-width: 480px) { 8 | #par { 9 | padding-left: 25px; 10 | padding-right: 25px; 11 | } 12 | 13 | h2 { 14 | font-size: 2.5em; 15 | } 16 | } 17 | 18 | p { 19 | font-weight: 300; 20 | } 21 | 22 | #buttons { 23 | display: flex; 24 | justify-content: center; 25 | flex-wrap: wrap; 26 | } 27 | 28 | .mbtn { 29 | margin: 12px; 30 | } 31 | 32 | .fineprint { 33 | font-size: 0.7em; 34 | } 35 | 36 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fireroad.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /courseupdater/static/courseupdater/css/style.css: -------------------------------------------------------------------------------- 1 | .diff-list { 2 | width: 100%; 3 | overflow-y: scroll !important; 4 | } 5 | 6 | .vertical-center { 7 | position: absolute; 8 | margin: auto; 9 | top: 0; 10 | right: 0; 11 | bottom: 0; 12 | left: 0; 13 | width: 400px; 14 | height: 100px; 15 | } 16 | 17 | @media (min-width: 767px) { 18 | .corrections-body { 19 | padding: 12px 30px 30px 30px; 20 | } 21 | 22 | .bottom-btn { 23 | position: absolute; 24 | bottom: 6px; 25 | right: 0; 26 | } 27 | } 28 | 29 | @media (max-width: 767px) { 30 | .corrections-body { 31 | padding: 12px 12px 36px 12px; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/update_success.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Update 5 | 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 |
10 | The catalog has been scheduled to update at the next database refresh. 11 |
12 | Cancel Update 13 | Home 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /fireroad/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for fireroad project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | # Add the root path for the Django server to sys.path so WSGI can find the 16 | # right settings module 17 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fireroad.settings") 19 | 20 | application = get_wsgi_application() 21 | -------------------------------------------------------------------------------- /analytics/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'total_requests/(?P[a-z-]*)', views.total_requests, name="total_requests"), 7 | url(r'user_agents/(?P[a-z-]*)', views.user_agents, name="user_agents"), 8 | url(r'logged_in_users/(?P[a-z-]*)', views.logged_in_users, name="logged_in_users"), 9 | url(r'user_semesters/(?P[a-z-]*)', views.user_semesters, name="user_semesters"), 10 | url(r'request_paths/(?P[a-z-]*)', views.request_paths, name="request_paths"), 11 | url(r'active_documents/(?P[a-z-]*)', views.active_documents, name="active_documents"), 12 | url(r'^$', views.dashboard, name='analytics_dashboard'), 13 | ] 14 | -------------------------------------------------------------------------------- /courseupdater/static/courseupdater/js/corrections.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $.ajax({ 3 | url: '/courses/all', 4 | type: 'get', 5 | cache: false, 6 | success: function (data) { 7 | var courses = {}; 8 | for (item in data) { 9 | courses[data[item].subject_id] = null; 10 | } 11 | $('input.autocomplete').autocomplete("updateData", courses); 12 | }, 13 | error: function (err) { 14 | console.log(err); 15 | } 16 | }); 17 | $('input.autocomplete').autocomplete({ 18 | data: {}, 19 | limit: 10, 20 | minLength: 1, // The minimum length of the input for the autocomplete to start. Default: 1. 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /common/templates/common/docs/base.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %} 5 | FireRoad API Reference 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 | {% block nav %} 10 | {% include "common/docs/sidebar.html" with active_id="" %} 11 | {% endblock %} 12 |
13 |
14 |
15 | {% block content %} {% endblock %} 16 |
17 |
18 |
19 | {% endblock %} 20 | 21 | {% block pagescripts %} 22 | 30 | {% block scripts %}{% endblock %} 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /common/static/common/css/shared.css: -------------------------------------------------------------------------------- 1 | .logo-img { 2 | vertical-align: middle; 3 | margin-top: -5px; 4 | margin-right: 12px; 5 | } 6 | @media screen and (max-width: 480px) { 7 | .logo-img { 8 | margin-right: 2px !important; 9 | } 10 | } 11 | .nav-wrapper { 12 | padding-left: 12px; 13 | } 14 | .mbtn { 15 | margin: 8px; 16 | } 17 | 18 | .material-icons{ 19 | display: inline-flex; 20 | vertical-align: top; 21 | } 22 | 23 | a { 24 | color: rgb(224, 20, 20); 25 | } 26 | 27 | .file-example-content { 28 | overflow-y: scroll; 29 | max-height: 240px; 30 | } 31 | 32 | pre.code { 33 | margin: 0 !important; 34 | } 35 | 36 | .json-key { 37 | color: #1976D2 !important; 38 | } 39 | 40 | .json-string-value { 41 | color: #e91e63 !important; 42 | } 43 | 44 | .json-number-value { 45 | color: #4CAF50 !important; 46 | } 47 | 48 | .login-pane { 49 | max-width: 450px; 50 | } -------------------------------------------------------------------------------- /requirements/static/requirements/js/edit.js: -------------------------------------------------------------------------------- 1 | var previewMode = 0; 2 | 3 | function onPreviewButtonClicked(editTitle, textSelector, editorSelector) { 4 | if (previewMode == 0) { 5 | // Go to preview 6 | $("#preview-button").text(editTitle); 7 | $("#preview-loading-ind").addClass("active"); 8 | previewMode = 1; 9 | $.ajax({ 10 | url: "/requirements/preview/", 11 | type: "POST", 12 | data: $(textSelector).val(), 13 | contentType:"text/plain; charset=utf-8", 14 | success: function(data) { 15 | $("#preview").html(data); 16 | $("#preview").toggle(); 17 | $("#preview-loading-ind").removeClass("active"); 18 | } 19 | }); 20 | 21 | } else { 22 | // Go back to edit 23 | $("#preview-button").text("Preview"); 24 | previewMode = 0; 25 | $("#preview").toggle(); 26 | } 27 | $(editorSelector).toggle(); 28 | } 29 | -------------------------------------------------------------------------------- /catalog_parse/utils/parse_equivalences.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .catalog_constants import * 3 | 4 | def parse_equivalences(equiv_path, courses): 5 | """ 6 | Reads equivalences from the given path, and adds them to the "Parent" and 7 | "Children" attributes of the appropriate courses. 8 | 9 | equiv_path: path to a JSON file containing equivalences, for example: 10 | [[["6.0001", "6.0002"], "6.00"], ...] 11 | courses: a dictionary of subject IDs to courses 12 | """ 13 | 14 | with open(equiv_path, 'r') as file: 15 | equiv_data = json.loads(file.read()) 16 | 17 | for equivalence in equiv_data: 18 | children, parent = equivalence 19 | for child in children: 20 | if child not in courses: continue 21 | courses[child][CourseAttribute.parent] = parent 22 | if parent in courses: 23 | courses[parent][CourseAttribute.children] = ','.join(children) 24 | -------------------------------------------------------------------------------- /courseupdater/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.index, name='index'), 7 | url('check/', views.check, name='check'), 8 | url('semesters/', views.semesters, name='semesters'), 9 | 10 | url(r'update_catalog/', views.update_catalog, name='update_catalog'), 11 | url(r'update_progress/', views.update_progress, name='update_progress'), 12 | url(r'reset_update/', views.reset_update, name='reset_update'), 13 | 14 | url(r'corrections/delete/(?P\d+)', views.delete_correction, name='delete_catalog_correction'), 15 | url(r'corrections/edit/(?P\d+)', views.edit_correction, name='edit_catalog_correction'), 16 | url(r'corrections/new', views.new_correction, name='new_catalog_correction'), 17 | url(r'corrections/', views.view_corrections, name='catalog_corrections'), 18 | 19 | url(r'download_data/', views.download_catalog_data, name='download_data') 20 | ] 21 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/start_update.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Update 5 | 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 |
10 | There is currently no catalog update in progress. Enter a semester name to start one. 11 |
12 |
13 |
14 | {% csrf_token %} 15 | {{ form.semester }} 16 | {{ form.semester.errors }} 17 |
18 |
19 | 23 |
24 |
25 | 26 | {{ form.non_field_errors }} 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /recommend/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.forms import ModelForm 3 | from django.contrib.auth.models import User 4 | 5 | MAX_RATING_VALUE = 5 6 | DEFAULT_RECOMMENDATION_TYPE = "for-you" 7 | 8 | # Create your models here. 9 | class Rating(models.Model): 10 | user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) 11 | #user_id = models.BigIntegerField(default=0) # DEPRECATED 12 | subject_id = models.CharField(max_length=50) 13 | value = models.IntegerField(default=0) 14 | 15 | def __str__(self): 16 | return "User {} rated {} as {}".format(self.user_id, self.subject_id, self.value) 17 | 18 | class Recommendation(models.Model): 19 | user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) 20 | #user_id = models.BigIntegerField(default=0) # DEPRECATED 21 | rec_type = models.CharField(max_length=20) 22 | subjects = models.CharField(max_length=500) 23 | 24 | def __str__(self): 25 | return "Recommendation ({}) for user {}: {}".format(self.rec_type, self.user_id, self.subjects) 26 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Venkatesh Sivaraman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /requirements/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | from . import editor 5 | 6 | from django.views.generic import TemplateView 7 | 8 | urlpatterns = [ 9 | url(r'^edit/(?P.{1,50})', editor.edit, name='requirements_edit'), 10 | url(r'^success/', editor.success, name='submit_success'), 11 | url(r'^create/', editor.create, name='create'), 12 | url(r'^preview/', editor.preview, name='preview'), 13 | url(r'^review/(?P\d+)', editor.review, name='review'), 14 | url(r'^review/', editor.review_all, name='review_all'), 15 | url(r'^resolve/(?P\d+)', editor.resolve, name='resolve'), 16 | url(r'^ignore_edit/(?P\d+)', editor.ignore_edit, name='ignore_edit'), 17 | url(r'^uncommit/(?P\d+)', editor.uncommit, name='uncommit'), 18 | url(r'^commit/(?P\d+)', editor.commit, name='commit'), 19 | url(r'^list_reqs/', views.list_reqs, name='list_reqs'), 20 | url(r'^get_json/(?P.{1,50})/', views.get_json, name='get_json'), 21 | url(r'^progress/(?P.{1,50})/(?P.+)', views.progress, name='progress'), 22 | url(r'^progress/(?P.{1,50})/', views.road_progress, name='road_progress'), 23 | url(r'^$', editor.index, name='requirements_index'), 24 | ] 25 | -------------------------------------------------------------------------------- /common/templates/common/docs/overview.html: -------------------------------------------------------------------------------- 1 | {% extends "common/docs/base.html" %} 2 | 3 | {% block nav %} 4 | {% include "common/docs/sidebar.html" with active_id="overview" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

FireRoad API Reference

9 |

The FireRoad server provides a comprehensive API, with both read-only interfaces for course catalogs and major/minor requirements and read-write endpoints for interfacing with users' roads and schedules.

10 |

The read-only API is open-access, and logging in with your MIT credentials allows you to test the read-write APIs within your own account. Please contact us if you would like to use the read-write APIs to create a FireRoad client that accesses other user data.

11 |

IMPORTANT: Please use the development server at fireroad-dev.mit.edu for all testing.

12 | 13 |
14 |
15 |
16 |
17 | 20 |
21 |

22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /common/templates/common/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block pagebody %} 5 |
6 |

Sync and Recommendations

7 |

Would you like to sync your roads and schedules with CourseRoad and across your devices? FireRoad will also provide you with personalized suggestions based on your subject selections.

8 |

Tap Allow and Continue to enable this feature. You can also do so later in the settings view.

9 |

Sync and Recommendations requires MIT credentials. By enabling this feature, you are allowing FireRoad to store your roads, schedules, and subject ratings on a secure MIT server. FireRoad uses various machine learning algorithms to provide you with subject recommendations and to improve other users' recommendations anonymously. Recommendations are solely suggestions - please consult with faculty and course administrators for the most reliable and up-to-date information.

10 |
11 |
12 | Allow and Continue 13 | Not Now 14 |
15 | 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /common/templates/common/client_approval.html: -------------------------------------------------------------------------------- 1 | {% extends "common/auth_base.html" %} 2 | {% load static %} 3 | 4 | {% block pagebody %} 5 |
6 |

Approve Client

7 |

"{{ client_name }}" is requesting access to your FireRoad account. This client will be granted the following capabilities:

8 |
    9 | {% for permission in client_permissions %} 10 |
  • {{ permission }}
  • 11 | {% endfor %} 12 |
13 | {% if is_debug %} 14 |

WARNING: This site is authenticating you through the FireRoad development server, which is subject to potential vulnerabilities and does not restrict client sites to specific permissions. This means of authentication must not be used for production applications.

15 | {% endif %} 16 |

Would you like to allow this?

17 |

If you have questions, comments, or concerns, please contact the developer of this client or the FireRoad dev team.

18 |
19 | Allow 20 | Don't Allow 21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /fireroad/settings_prod.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for fireroad project in prod environment only. 3 | """ 4 | 5 | import os 6 | from .settings import * 7 | 8 | # Location of catalog files on the server 9 | CATALOG_BASE_DIR = "/var/www/html/catalogs" 10 | 11 | # URL used to log in (e.g. to admin) 12 | LOGIN_URL = "/login_touchstone" 13 | 14 | # Security settings 15 | RESTRICT_AUTH_REDIRECTS = True 16 | DEBUG = False 17 | 18 | # For building URLs and validating hosts 19 | ALLOWED_HOSTS = ['fireroad.mit.edu'] 20 | MY_BASE_URL = 'https://fireroad.mit.edu' 21 | 22 | # MySQL database 23 | import dbcreds 24 | 25 | DATABASES = { 26 | 'default': { 27 | 'ENGINE': 'django.db.backends.mysql', 28 | 'NAME': dbcreds.dbname, # For scripts: venkats+fireroad 29 | 'USER': dbcreds.username, 30 | 'PASSWORD': dbcreds.password, 31 | 'HOST': dbcreds.host, # For scripts: sql.mit.edu 32 | 'PORT': '3306', 33 | } 34 | } 35 | 36 | MIDDLEWARE_CLASSES = [ 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.middleware.csrf.CsrfViewMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | 'django.contrib.messages.middleware.MessageMiddleware', 42 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 43 | 'analytics.request_counter.RequestCounterMiddleware' 44 | ] 45 | -------------------------------------------------------------------------------- /setup_catalog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script handles setting up the catalog for local development. 4 | 5 | GREEN='\033[0;32m' 6 | NC='\033[0m' # No Color 7 | 8 | CATALOG_DIR=$( python -c "from fireroad.settings import CATALOG_BASE_DIR; print(CATALOG_BASE_DIR)" ) 9 | if [ ! -d $CATALOG_DIR ]; then 10 | echo "It looks like the course catalog directory is currently empty. You can download a copy (https://fireroad.mit.edu/courseupdater/download_data) and unzip it in the data directory." 11 | echo 12 | echo "Alternatively, you can begin without initial data using the instructions in data/readme.md." 13 | echo 14 | echo 15 | 16 | read -p "Would you like to begin without initial data? (y/n) " ready 17 | if [[ $ready != "y" ]]; then 18 | echo "Run this script again after downloading and placing the catalog data." 19 | exit 0 20 | fi 21 | 22 | # Set up directories 23 | mkdir -p $CATALOG_DIR 24 | mkdir -p $CATALOG_DIR/raw 25 | mkdir -p $CATALOG_DIR/requirements 26 | mkdir -p $CATALOG_DIR/deltas 27 | mkdir -p $CATALOG_DIR/deltas/requirements 28 | 29 | echo "Done creating directories at $CATALOG_DIR. Follow the instructions in data/readme.md to run the catalog parser and create requirements lists." 30 | exit 0 31 | fi 32 | 33 | # Set location of settings file 34 | export DJANGO_SETTINGS_MODULE="fireroad.settings" 35 | echo -e "${GREEN}Running database update script...${NC}" 36 | python update_db.py -------------------------------------------------------------------------------- /fireroad/settings_dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for fireroad project in dev server environment only. 3 | """ 4 | 5 | import os 6 | from .settings import * 7 | 8 | # Location of catalog files on the server 9 | CATALOG_BASE_DIR = "/var/www/html/catalogs" 10 | 11 | # URL used to log in 12 | LOGIN_URL = "/login_touchstone" 13 | 14 | # Security settings more relaxed on dev server 15 | RESTRICT_AUTH_REDIRECTS = False 16 | DEBUG = True 17 | 18 | # For building URLs and validating hosts 19 | ALLOWED_HOSTS = ['fireroad-dev.mit.edu'] 20 | MY_BASE_URL = 'https://fireroad-dev.mit.edu' 21 | 22 | # MySQL database 23 | import dbcreds 24 | 25 | DATABASES = { 26 | 'default': { 27 | 'ENGINE': 'django.db.backends.mysql', 28 | 'NAME': dbcreds.dbname, # For scripts: venkats+fireroad 29 | 'USER': dbcreds.username, 30 | 'PASSWORD': dbcreds.password, 31 | 'HOST': dbcreds.host, # For scripts: sql.mit.edu 32 | 'PORT': '3306', 33 | } 34 | } 35 | 36 | MIDDLEWARE_CLASSES = [ 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.middleware.csrf.CsrfViewMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | 'django.contrib.messages.middleware.MessageMiddleware', 42 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 43 | 'analytics.request_counter.RequestCounterMiddleware' 44 | ] 45 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/corrections.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Corrections 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block pagebody %} 10 |
11 |
12 |
13 |

Catalog Corrections

14 |
15 | add New Correction 16 |
17 |
18 |
    19 | {% for correction in diffs %} 20 |
  • 21 | Delete 22 | Edit 23 |
    {{ correction.subject_id }}
    24 | {% for key, diff in correction.diff.items %} 25 |

    {{ key }}  {% if diff.0 %}{{ diff.0 }}{% endif %}{{ diff.1 }}

    26 | {% endfor %} 27 |
  • 28 | {% endfor %} 29 |
30 |
31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/review_update.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Update 5 | 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 |
10 |
11 |
12 |

Review Catalog Update

13 |
14 |
15 |
16 | {% for diff in diffs %} 17 |
  • 18 | {% autoescape off %}{{ diff }}{% endautoescape %} 19 |
  • 20 | {% endfor %} 21 |
    22 |
    23 |

    Review the changes above, then press Deploy Update to publish the changes at the next nightly database refresh.

    24 |
    25 | {% csrf_token %} 26 | 29 | Reset 30 | {{ form.non_field_errors }} 31 | {{ form.contents }} 32 |
    33 |
    34 |
    35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /common/templates/common/docs/recommender.html: -------------------------------------------------------------------------------- 1 | {% extends "common/docs/base.html" %} 2 | 3 | {% block nav %} 4 | {% include "common/docs/sidebar.html" with active_id="recommender" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

    Recommender

    9 |

    Use these endpoints to update and access various course recommendations generated by the FireRoad recommendation engine.

    10 | 11 |
    /recommend/rate (POST)
    12 |

    Sends a subject rating to the FireRoad server. The body of the request should be a JSON list of dictionaries, each containing s (subject ID) and v (rating value). Updates the ratings for each item.

    13 | 14 |
    /recommend/get (GET)
    15 |

    Takes an optional parameter t indicating the type of recommendation to return. Returns a dictionary of recommendation types mapped to JSON strings indicating the recommended subjects and their rating values.

    16 | 17 |
    18 |
    19 | chevron_left Sync 20 |
    21 |
    22 | 25 |
    26 |

    27 |
    28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### OSX ### 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear on external disk 16 | .Spotlight-V100 17 | .Trashes 18 | 19 | # Directories potentially created on remote AFP share 20 | .AppleDB 21 | .AppleDesktop 22 | Network Trash Folder 23 | Temporary Items 24 | .apdisk 25 | 26 | 27 | ### Python ### 28 | # Byte-compiled / optimized / DLL files 29 | __pycache__/ 30 | *.py[cod] 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | env/ 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | *.egg-info/ 49 | .installed.cfg 50 | *.egg 51 | 52 | # PyInstaller 53 | # Usually these files are written by a python script from a template 54 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 55 | *.manifest 56 | *.spec 57 | 58 | # Installer logs 59 | pip-log.txt 60 | pip-delete-this-directory.txt 61 | 62 | # Unit test / coverage reports 63 | htmlcov/ 64 | .tox/ 65 | .coverage 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | 81 | ### Django ### 82 | *.log 83 | *.pot 84 | *.pyc 85 | __pycache__/ 86 | local_settings.py 87 | 88 | .env 89 | db.sqlite3 90 | 91 | .ipynb_checkpoints 92 | */migrations/* 93 | static/**/* 94 | 95 | data/catalogs/** -------------------------------------------------------------------------------- /common/templates/common/docs/sidebar.html: -------------------------------------------------------------------------------- 1 | 32 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SECRETPATH="fireroad/secret.txt" 4 | DBCREDPATH="fireroad/dbcreds.py" 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | NC='\033[0m' # No Color 9 | 10 | # Installing dependencies 11 | pip install django==1.11.15 pandas nltk==3.4 lxml scipy scikit-learn requests pyjwt==1.6.4 12 | echo 13 | echo 14 | 15 | # Secret key 16 | if [ ! -f $SECRETPATH ]; then 17 | openssl rand -base64 80 > $SECRETPATH 18 | echo -e "${GREEN}Generated secret key at $SECRETPATH ${NC}" 19 | fi 20 | 21 | # Prompt for database type 22 | BACKEND=$( python -c "from fireroad.settings import DATABASES; print(DATABASES['default']['ENGINE'])" ) 23 | NAME=$( python -c "from fireroad.settings import DATABASES; print(DATABASES['default']['NAME'])" ) 24 | read -p "You are set to use the database '$NAME' (backend: $BACKEND). Would you like to migrate to this backend? (y/n) " keepbackend 25 | if [[ $keepbackend == "y" ]]; then 26 | # Migrate database 27 | echo "Migrating to database $NAME..." 28 | 29 | # Migrations 30 | python manage.py makemigrations analytics common catalog courseupdater sync recommend requirements 31 | read -p "Ready to migrate? (y/n) " ready 32 | if [[ $ready != "y" ]]; then 33 | echo "Use the following command to migrate the database when ready:" 34 | echo 35 | echo " python manage.py migrate" 36 | echo 37 | exit 0 38 | fi 39 | python manage.py migrate 40 | 41 | echo "Done migrating." 42 | exit 0 43 | elif [[ $keepbackend == "n" ]]; then 44 | echo -e "${YELLOW}Please modify the fireroad/settings.py file to use the appropriate database, or specify a different settings module when running the server (such as settings_dev or settings_prod).${NC}" 45 | else 46 | echo -e "${RED}Unrecognized symbol $keepbackend; quitting ${NC}" 47 | exit 1 48 | fi 49 | 50 | 51 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/update_progress.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Catalog Update 5 | 6 | {% endblock %} 7 | 8 | {% block pagebody %} 9 |
    10 | {% if not update.is_started %} 11 |

    Waiting to start update...

    12 | {% else %} 13 |

    {{ update.progress_message }}

    14 | {% endif %} 15 |
    16 |
    17 |
    18 |
    19 |
    20 | Cancel Update 21 |
    22 | {% endblock %} 23 | 24 | {% block pagescripts %} 25 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /analytics/request_counter.py: -------------------------------------------------------------------------------- 1 | """This module counts basic information about incoming requests to the 2 | FireRoad server. 3 | 4 | NOTE: The data that is collected contains potential user-identifying 5 | information (namely, the user's unique ID and current semester). This info will 6 | not leave the FireRoad server unless it is aggregated such that users are no 7 | longer identifiable. 8 | """ 9 | 10 | from .models import * 11 | 12 | EXCLUDE_PATH_PREFIXES = [ 13 | "/favicon.ico", 14 | "/admin", 15 | "/analytics", 16 | "/apple" 17 | ] 18 | 19 | EXCLUDE_USER_AGENTS = [ 20 | "check_http", 21 | "monitoring", 22 | "bot", 23 | "crawl", 24 | "spider" 25 | ] 26 | 27 | class RequestCounterMiddleware(object): 28 | """A middleware that saves a RequestCount object each time a page is requested.""" 29 | 30 | def process_response(self, request, response): 31 | """Called after Django calls the request's view. We will use this hook 32 | to log basic information about the request (performed after request 33 | to make sure we have login info).""" 34 | if any(request.path.startswith(prefix) for prefix in EXCLUDE_PATH_PREFIXES): 35 | return response 36 | user_agent = request.META.get("HTTP_USER_AGENT", "") 37 | if any(element in user_agent.lower() for element in EXCLUDE_USER_AGENTS): 38 | return response 39 | 40 | tally = RequestCount.objects.create() 41 | tally.path = request.path 42 | if len(user_agent) > 150: 43 | user_agent = user_agent[:150] 44 | tally.user_agent = user_agent 45 | if hasattr(request, "user") and request.user and request.user.is_authenticated(): 46 | tally.is_authenticated = True 47 | try: 48 | student = request.user.student 49 | except: 50 | pass 51 | else: 52 | tally.student_unique_id = student.unique_id 53 | tally.student_semester = student.current_semester 54 | tally.save() 55 | return response 56 | -------------------------------------------------------------------------------- /common/templates/common/auth_base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block title %} 15 | FireRoad 16 | {% endblock %} 17 | 26 | 27 | 28 | 29 | 30 | 37 | 38 | {% block pagebody %} {% endblock %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | {% block pagescripts %}{% endblock %} 46 | 47 | 48 | -------------------------------------------------------------------------------- /common/templates/common/index.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad 5 | 33 | {% endblock %} 34 | 35 | {% block pagebody %} 36 |
    37 |
    38 |
    39 |

    40 |

    Light your path through MIT with FireRoad.

    41 |

    FireRoad is the new best way to plan your path through MIT. Plan for both the upcoming semester and the years ahead, all in one place. You can view up-to-date course requirements and add them directly to your roads, and browse subjects by any criteria. FireRoad even learns from your selections to recommend other subjects you might be interested in!

    42 |
    43 |
    44 |

    Download the app now:

    45 |
    46 |

    App Store Google Play Store

    47 |
    48 |
    49 |
    50 |
    51 | Created by Venkatesh Sivaraman, MIT 2020. 52 |
    53 |
    54 |
    55 |
    56 |
    57 | 58 |
    59 |
    60 |
    61 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /fireroad/urls.py: -------------------------------------------------------------------------------- 1 | """fireroad URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib.admin.views.decorators import staff_member_required 18 | from django.views.generic.base import RedirectView 19 | from django.contrib import admin 20 | from common.views import dev_login 21 | from django.conf import settings 22 | 23 | # admin.autodiscover() 24 | # admin.site.login = staff_member_required(admin.site.login, login_url=settings.LOGIN_URL) 25 | 26 | urlpatterns = [ 27 | url(r'courses/', include('catalog.urls')), 28 | url(r'courseupdater/', include('courseupdater.urls')), 29 | url(r'recommend/', include('recommend.urls')), 30 | url(r'admin/', admin.site.urls), 31 | url(r'sync/', include('sync.urls')), 32 | url(r'analytics/', include('analytics.urls')), 33 | url(r'requirements/', include('requirements.urls')), 34 | url(r'', include('common.urls')), 35 | ] 36 | 37 | # Redirect to the appropriate login page if one is specified in the settings module 38 | if settings.LOGIN_URL: 39 | if settings.LOGIN_URL.strip("/") != 'dev_login': 40 | urlpatterns.insert(0, url(r'^admin/login/$', RedirectView.as_view(url=settings.LOGIN_URL, 41 | permanent=True, 42 | query_string=True))) 43 | urlpatterns.insert(0, url(r'^dev_login/$', RedirectView.as_view(url=settings.LOGIN_URL, 44 | permanent=True, 45 | query_string=True))) 46 | if settings.LOGIN_URL.strip("/") != 'login': 47 | urlpatterns.insert(0, url(r'^login/$', RedirectView.as_view(url=settings.LOGIN_URL, 48 | permanent=True, 49 | query_string=True))) 50 | 51 | 52 | -------------------------------------------------------------------------------- /requirements/templates/requirements/review.html: -------------------------------------------------------------------------------- 1 | {% extends "requirements/base.html" %} 2 | {% block title %} 3 | {{ action }} {{ medium_title }} 4 | {% endblock %} 5 | {% block content %} 6 |
    7 |
    8 |

    {{ action }} {{ medium_title }}

    9 |
    10 | {% if edit_req.type == "Create" %} 11 | Edit 12 | {% else %} 13 | {% if edit_req.list_id %} 14 | Edit 15 | {% endif %} 16 | {% endif %} 17 | Preview 18 |
    19 |
    20 | 21 |
    {% autoescape off %}{{ diff }}{% endautoescape %}
    22 | 23 |
    24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 |
    31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |

    Edit request submitted by {{ edit_req.email_address|urlize }} on {{ edit_req.timestamp|date:'M d Y' }}.

    38 |

    Reason: {{ edit_req.reason|urlize }}

    39 |
    40 |
    41 | {% if edit_req.committed %} 42 | Uncommit 43 | {% else %} 44 | Commit 45 | {% endif %} 46 | Cancel 47 |
    48 |
    49 | {% endblock %} 50 | {% block scripts %} 51 | 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /common/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | from django.views.generic import TemplateView 6 | from django.contrib.auth.views import logout 7 | 8 | urlpatterns = [ 9 | url('verify/', views.verify, name='verify'), 10 | url('new_user/', views.new_user, name='new_user'), 11 | url('signup/', views.signup, name='signup'), 12 | url('^login/', views.login_oauth, name='login'), 13 | url('^dev_login/', views.dev_login, name='dev_login'), 14 | url('login_touchstone/', views.login_touchstone, name='login_touchstone'), 15 | url('logout/', logout, {'next_page': 'index'}, name='logout'), 16 | url('set_semester/', views.set_semester, name='set_semester'), 17 | url('prefs/favorites/', views.favorites, name='favorites'), 18 | url('prefs/set_favorites/', views.set_favorites, name='set_favorites'), 19 | url('prefs/progress_overrides/', views.progress_overrides, name='progress_overrides'), 20 | url('prefs/set_progress_overrides/', views.set_progress_overrides, name='set_progress_overrides'), 21 | url('prefs/notes/', views.notes, name='notes'), 22 | url('prefs/set_notes/', views.set_notes, name='set_notes'), 23 | url('prefs/custom_courses/', views.custom_courses, name='custom_courses'), 24 | url('prefs/set_custom_course/', views.set_custom_course, name='set_custom_course'), 25 | url('prefs/remove_custom_course/', views.remove_custom_course, name='remove_custom_course'), 26 | url('decline/', TemplateView.as_view(template_name='common/decline.html'), name='decline'), 27 | url('fetch_token/', views.fetch_token, name='fetch_token'), 28 | url('user_info/', views.user_info, name='user_info'), 29 | url('^disapprove_client/', views.approval_page_failure, name='approval_page_failure'), 30 | url('^approve_client/', views.approval_page_success, name='approval_page_success'), 31 | 32 | # reference 33 | url('reference/$', TemplateView.as_view(template_name='common/docs/overview.html'), name='overview'), 34 | url('reference/auth', TemplateView.as_view(template_name='common/docs/auth.html'), name='auth'), 35 | url('reference/catalog', TemplateView.as_view(template_name='common/docs/catalog.html'), name='catalog'), 36 | url('reference/requirements', TemplateView.as_view(template_name='common/docs/requirements.html'), name='requirements'), 37 | url('reference/sync', TemplateView.as_view(template_name='common/docs/sync.html'), name='sync'), 38 | url('reference/recommender', TemplateView.as_view(template_name='common/docs/recommender.html'), name='recommender'), 39 | url('reference/file_formats', TemplateView.as_view(template_name='common/docs/file_formats.html'), name='file_formats'), 40 | 41 | # index 42 | url(r'^$', TemplateView.as_view(template_name='common/index.html'), name='index'), 43 | ] 44 | -------------------------------------------------------------------------------- /courseupdater/templates/courseupdater/edit_correction.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | Edit Catalog Correction 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block pagebody %} 10 |
    11 |
    12 |
    13 |

    {% if is_new %}New Correction{% else %}Edit Correction{% endif %}

    14 |
    15 |
    16 |

    Enter a subject ID and values for the fields that you would like to modify. Original values from the catalog parser will be used for any fields left blank. Hint: You can use the '*' character in the subject ID to match any number in a position.

    17 |
    18 |
    19 |
    20 | {% csrf_token %} 21 |
    22 |
    23 |
    24 | 25 | 26 |
    27 |
    28 | {% for field in form %} 29 | {% if field.name == "subject_id" or "offered" in field.name or "is_" in field.name %}{% else %} 30 |
    31 |
    32 | 33 | 34 |
    35 |
    36 | {% endif %} 37 | {% endfor %} 38 |
    39 |
    40 |
    41 | {% for field in form %} 42 | {% if "offered" not in field.name and "is_" not in field.name %}{% else %} 43 |

    44 | 48 |

    49 | {% endif %} 50 | {% endfor %} 51 |
    52 |
    53 |
    54 | Cancel 55 | 56 |
    57 |
    58 |
    59 | {% endblock %} 60 | 61 | {% block pagescripts %} 62 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /analytics/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | class RequestCount(models.Model): 5 | """Keeps track of a single request.""" 6 | 7 | path = models.CharField(max_length=50, null=True) 8 | timestamp = models.DateTimeField(auto_now_add=True, db_index=True) 9 | user_agent = models.CharField(max_length=150, null=True) 10 | is_authenticated = models.BooleanField(default=False) 11 | student_unique_id = models.CharField(max_length=50, null=True) 12 | student_semester = models.CharField(max_length=25, null=True) 13 | 14 | def __str__(self): 15 | return "{} by {} at {}{}".format( 16 | self.path, 17 | self.user_agent, 18 | self.timestamp, 19 | " (logged in)" if self.is_authenticated else "" 20 | ) 21 | 22 | @staticmethod 23 | def tabulate_requests(early_time, interval=None, attribute_func=None, distinct_users=False): 24 | """Retrieves request counts from the given time to present, 25 | bucketed by the given interval. 26 | 27 | Args: 28 | early_time: A timezone.datetime object indicating the minimum time 29 | to retrieve requests for. 30 | interval: A timezone.timedelta object indicating the period of time 31 | spanned by each returned bucket. If None, counts all requests 32 | together and returns a single dictionary. 33 | attribute_func: A function taking a RequestCount and returning a 34 | value to tabulate for each bucket. 35 | distinct_users: If True, count only one request per unique user. 36 | 37 | Returns: 38 | A list of tuples (time, dict), where time is a timezone.datetime 39 | object indicating the start time of the bucket, and dict is a 40 | dictionary mapping values returned by attribute_func to their 41 | counts in the bucket. 42 | """ 43 | now = timezone.now() 44 | buckets = [] 45 | if interval: 46 | curr = early_time 47 | while curr < now: 48 | buckets.append((curr, {})) 49 | curr += interval 50 | else: 51 | buckets.append((early_time, {})) 52 | 53 | seen_users = set() 54 | for request in RequestCount.objects.filter(timestamp__gte=early_time).iterator(): 55 | if distinct_users and (not request.student_unique_id or request.student_unique_id in seen_users): 56 | continue 57 | seen_users.add(request.student_unique_id) 58 | for time, bucket in buckets: 59 | if request.timestamp >= time and (not interval or request.timestamp < time + interval): 60 | value = attribute_func(request) if attribute_func else None 61 | bucket[value] = bucket.get(value, 0) + 1 62 | break 63 | 64 | if not interval: 65 | return buckets[0][1] 66 | return buckets 67 | -------------------------------------------------------------------------------- /recommend/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.http import HttpResponse, HttpResponseBadRequest 3 | from django.views.decorators.csrf import csrf_exempt 4 | from .models import * 5 | from common.models import * 6 | import random 7 | from django.contrib.auth import login, authenticate, logout 8 | from django.core.exceptions import PermissionDenied 9 | from common.decorators import logged_in_or_basicauth, require_token_permissions 10 | import json 11 | from django.utils import timezone 12 | from dateutil.relativedelta import relativedelta 13 | 14 | def update_rating(user, subject_id, value): 15 | Rating.objects.filter(user=user, subject_id=subject_id).delete() 16 | 17 | r = Rating(user=user, subject_id=subject_id, value=value) 18 | r.save() 19 | 20 | @logged_in_or_basicauth 21 | @require_token_permissions("can_view_recommendations") 22 | def get(request): 23 | rec_type = request.GET.get('t', '') 24 | if len(rec_type) == 0: 25 | recs = Recommendation.objects.filter(user=request.user) 26 | else: 27 | recs = Recommendation.objects.filter(user=request.user, rec_type=rec_type) 28 | if recs.count() == 0: 29 | return HttpResponse('No recommendations yet. Try again tomorrow!') 30 | resp = {rec.rec_type: json.loads(rec.subjects) for rec in recs} 31 | return HttpResponse(json.dumps(resp), content_type="application/json") 32 | 33 | @csrf_exempt 34 | @logged_in_or_basicauth 35 | @require_token_permissions("can_view_recommendations", "can_edit_student_info") 36 | def rate(request): 37 | batch = request.body 38 | if len(batch) > 0: 39 | batch_items = json.loads(batch) 40 | for item in batch_items: 41 | try: 42 | value = int(item['v']) 43 | if item['s'] == None or len(item['s']) > 10: 44 | return HttpResponseBadRequest('

    Missing subject ID

    ') 45 | if value is None: 46 | return HttpResponseBadRequest('

    Missing rating value

    ') 47 | update_rating(request.user, item['s'], value) 48 | except: 49 | return HttpResponseBadRequest('

    Bad input

    ') 50 | resp = { 'received': True } 51 | else: 52 | subject_id = request.POST.get('s', '') 53 | value = request.POST.get('v', '') 54 | try: 55 | value = int(value) 56 | if subject_id == None or len(subject_id) == 0 or len(subject_id) > 10: 57 | return HttpResponseBadRequest('

    Missing subject ID

    ') 58 | if value is None: 59 | return HttpResponseBadRequest('

    Missing rating value

    ') 60 | if request.user.username != str(user_id): 61 | raise PermissionDenied 62 | update_rating(request.user, subject_id, value) 63 | resp = { 'u': request.user.username, 's': subject_id, 'v': value, 'received': True } 64 | except: 65 | return HttpResponseBadRequest('

    Bad input

    ') 66 | return HttpResponse(json.dumps(resp), content_type="application/json") 67 | -------------------------------------------------------------------------------- /requirements/static/requirements/css/editor.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | resize: none; 3 | border: none; 4 | display: table-cell; 5 | flex: 1; /* same as flex: 1 1 auto; */ 6 | } 7 | .code, .editor { 8 | font-family: 'Inconsolata', Menlo, Monaco, Courier, monospace; 9 | line-height: 1.5em; 10 | } 11 | .code { 12 | padding: 2px; 13 | } 14 | .editor-card { 15 | width: 100%; 16 | padding: 8px; 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | .editor-card { 21 | flex-grow: 1; 22 | margin-bottom: 32px !important; 23 | } 24 | .content { 25 | margin-left: 32px; 26 | margin-right: 32px; 27 | display: flex; 28 | flex-direction: column; 29 | margin-bottom: 12px; 30 | } 31 | .content-full-screen { 32 | margin-left: 32px; 33 | margin-right: 32px; 34 | display: flex; 35 | flex-direction: column; 36 | margin-bottom: 12px; 37 | } 38 | @media (min-width: 767px) { 39 | .edit-head { 40 | margin-top: 12px; 41 | } 42 | .content { 43 | margin-top: -64px; 44 | padding-top: 64px; 45 | } 46 | .content-full-screen { 47 | margin-top: -64px; 48 | padding-top: 64px; 49 | } 50 | } 51 | .mbtn:hover { 52 | color: #940404; 53 | text-decoration: none; 54 | } 55 | .input-field:focus { 56 | border-bottom: 1px solid #a10000 !important; 57 | box-shadow: 0 1px 0 0 #a10000 !important; 58 | } 59 | ::-webkit-input-placeholder { color: #996767 !important; } 60 | :-moz-placeholder { /* Firefox 18- */ color: #996767 !important; } 61 | ::-moz-placeholder { /* Firefox 19+ */ color: #996767 !important; } 62 | :-ms-input-placeholder { color: #996767 !important; } 63 | 64 | .real-ul > li { 65 | margin-left: 20px !important; 66 | list-style-type: square !important; 67 | } 68 | 69 | ::selection { 70 | background-color: #f46565; 71 | } 72 | 73 | body, html, .content, .content-full-screen { 74 | height: 100%; 75 | } 76 | 77 | .diff-line { 78 | margin-bottom: -8px; 79 | word-wrap: break-word; 80 | } 81 | 82 | .insertion { 83 | color: rgb(48, 162, 93); 84 | background-color: rgba(82, 213, 134, 0.2); 85 | padding: 2px; 86 | border-radius: 2px; 87 | } 88 | 89 | .deletion { 90 | color: rgb(179, 32, 1); 91 | background-color: rgba(210, 47, 12, 0.2); 92 | padding: 2px; 93 | border-radius: 2px; 94 | text-decoration: line-through; 95 | } 96 | 97 | .edit-req-list-card { 98 | width: 100%; 99 | padding: 8px; 100 | display: flex; 101 | flex-direction: column; 102 | flex-grow: 1; 103 | } 104 | 105 | .edit-req-list-row { 106 | display: flex; 107 | flex-direction: row; 108 | overflow: hidden; 109 | } 110 | 111 | .edit-req-list-left { 112 | display: flex; 113 | flex-direction: column; 114 | width: 50%; 115 | margin-right: 12px; 116 | } 117 | 118 | .edit-req-list-right { 119 | display: flex; 120 | flex-direction: column; 121 | width: 50%; 122 | } 123 | 124 | .edit-req-list { 125 | overflow-y: scroll !important; 126 | } 127 | 128 | .edit-req-list > .collection-item:hover { 129 | background-color: #eeeeee; 130 | -webkit-transition: background-color 250ms linear; 131 | -ms-transition: background-color 2500ms linear; 132 | transition: background-color 250ms linear; 133 | } 134 | -------------------------------------------------------------------------------- /catalog_parse/utils/parse_evaluations.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .catalog_constants import * 3 | import json 4 | import requests 5 | 6 | class EvaluationConstants: 7 | rating = "rating" 8 | term = "term" 9 | in_class_hours = "ic_hours" 10 | out_of_class_hours = "oc_hours" 11 | eligible_raters = "eligible" 12 | responded_raters = "resp" 13 | iap_term = "JA" 14 | 15 | KEYS_TO_AVERAGE = { 16 | EvaluationConstants.rating: CourseAttribute.averageRating, 17 | EvaluationConstants.in_class_hours: CourseAttribute.averageInClassHours, 18 | EvaluationConstants.out_of_class_hours: CourseAttribute.averageOutOfClassHours, 19 | EvaluationConstants.eligible_raters: CourseAttribute.enrollment 20 | } 21 | 22 | def load_evaluation_data(eval_path): 23 | """ 24 | Reads evaluation data from the given .js file. 25 | """ 26 | 27 | with open(eval_path, 'r') as file: 28 | eval_contents = file.read() 29 | begin_range = eval_contents.find("{") 30 | end_range = eval_contents.rfind(";") 31 | return json.loads(eval_contents[begin_range:end_range]) 32 | 33 | def parse_evaluations(evals, courses): 34 | """ 35 | Adds attributes to each course based on eval data in the given dictionary. 36 | """ 37 | for i, course in courses.iterrows(): 38 | # i is the index of the DataFrame, which is the subject ID 39 | subject_ids = list(filter(None, [i, course[CourseAttribute.oldID]])) 40 | if not any(x in evals for x in subject_ids): 41 | continue 42 | 43 | averaging_data = {} 44 | for subject_id in subject_ids: 45 | if subject_id not in evals: 46 | continue 47 | for term_data in evals[subject_id]: 48 | # if course is offered fall/spring but an eval is for IAP, ignore 49 | if (EvaluationConstants.iap_term in term_data[EvaluationConstants.term] and 50 | (course[CourseAttribute.offeredFall] == "Y" or 51 | course[CourseAttribute.offeredSpring] == "Y")): 52 | continue 53 | # if no respondents, ignore 54 | if term_data[EvaluationConstants.responded_raters] == 0: 55 | continue 56 | 57 | for key in KEYS_TO_AVERAGE: 58 | if key not in term_data: 59 | continue 60 | value = term_data[key] 61 | # Get which academic year this is, so that we can weight 62 | # appropriately in the average 63 | year = int(term_data[EvaluationConstants.term][:-2]) 64 | averaging_data.setdefault(key, []).append((value, year)) 65 | 66 | for eval_key, course_key in KEYS_TO_AVERAGE.items(): 67 | if eval_key not in averaging_data: continue 68 | values = [value for value, year in averaging_data[eval_key]] 69 | max_year = max(year for value, year in averaging_data[eval_key]) 70 | weights = [0.5 ** (max_year - year) 71 | for value, year in averaging_data[eval_key]] 72 | total = sum(v * w for v, w in zip(values, weights)) 73 | average = total / sum(weights) 74 | course.loc[course_key] = "{:.2f}".format(average) 75 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # FireRoad Server 2 | 3 | FireRoad is an iOS/Android application that allows MIT students to plan their course selections, view up-to-date major/minor requirements, and discover new courses. The FireRoad Server is a Django server that provides a data backend and document "cloud" for the native apps as well as the web-based CourseRoad application. 4 | 5 | ## Cloning and Setup 6 | 7 | Follow these instructions to set up and run your own instance of the FireRoad server. You may want to create a new virtual environment using `conda`, for example: 8 | 9 | ``` 10 | conda create -n fireroad python=2.7 11 | conda activate fireroad 12 | ``` 13 | 14 | Then, enter the repo directory and run the setup script, which will install any necessary packages and set up the database. 15 | 16 | ``` 17 | cd fireroad-server 18 | ./setup.sh 19 | ``` 20 | 21 | To set up a catalog (including courses and requirements lists): 22 | 23 | ``` 24 | ./setup_catalog.sh 25 | ``` 26 | 27 | This script will prompt you to download a copy of the course catalog from the [prod site](https://fireroad.mit.edu/courseupdater/download_data) if you have not already. Otherwise, you can run the catalog setup script without pre-initialized catalogs, then run the scraper yourself following the instructions in `data/readme.md`. 28 | 29 | ## Running the Server 30 | 31 | To run the server (with the conda environment activated), use 32 | 33 | ``` 34 | python manage.py runserver 35 | ``` 36 | 37 | By default, the server runs on port 8000. You can specify a different port by simply adding the port number after the `runserver` command. (The `manage.py` script is [provided by Django](https://docs.djangoproject.com/en/1.11/ref/django-admin/) and provides other useful commands such as migrating the database, collecting static files, and opening an interactive shell.) 38 | 39 | ## Database Settings 40 | 41 | Note that the project contains three Django settings modules: `fireroad/settings.py` (local development), `fireroad/settings_dev.py` (dev server), and `fireroad_settings_prod.py` (prod server). When making changes to the settings, please make sure to change the file appropriate to the environment on which you want the changes to take effect (and note that the latter two import the base `settings.py` file). In order to specify which settings module should be used, you will need to set the `DJANGO_SETTINGS_MODULE` environment variable to `fireroad.settings{VARIANT}`, and change the default value specified in `fireroad/wsgi.py` if deploying with WSGI. 42 | 43 | Depending on your settings, there may be additional files that you can add to enable certain capabilities: 44 | 45 | * To use a MySQL database, add a `fireroad/dbcreds.py` file that specifies the necessary authentication info as Python variables `dbname`, `username`, `password`, and `host`. 46 | * To enable sending emails to admins for unresolved edit requests, etc., create an email address with two-factor authentication disabled (gmail works well). Then add a `fireroad/email_creds.py` file that specifies authentication info as a comma-delimited string with three components: the email server (e.g. `smtp.gmail.com`), the email address, and the password for the email account. 47 | 48 | ## API Endpoints 49 | 50 | The FireRoad API is fully documented at [fireroad.mit.edu/reference](https://fireroad.mit.edu/reference) (dev version at [fireroad-dev.mit.edu/reference](https://fireroad-dev.mit.edu/reference)). When submitting PRs that modify the behavior of these endpoints or add new ones, please update the docs in `common/templates/docs` accordingly. 51 | -------------------------------------------------------------------------------- /requirements/templates/requirements/base.html: -------------------------------------------------------------------------------- 1 | {% extends "common/base.html" %} 2 | {% load static %} 3 | {% block title %} 4 | FireRoad Requirements Editor 5 | {% endblock %} 6 | 7 | {% block pagebody %} 8 | 59 | {% endblock %} 60 |
    61 | {% block content %} {% endblock %} 62 |
    63 | {% endblock %} 64 | 65 | {% block pagescripts %} 66 | 74 | {% block scripts %}{% endblock %} 75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /data/readme.md: -------------------------------------------------------------------------------- 1 | # FireRoad Catalog Data 2 | 3 | FireRoad's functionality depends on managing an up-to-date data source for both course catalogs and requirements. This data is expected to be located in the directory named in the settings file (`fireroad.settings` for local testing, `fireroad.settings_dev` for the dev server, and `fireroad.settings_prod` for the prod server). You can obtain an up-to-date copy of the data through the Django admin menu on either deployed service. 4 | 5 | The catalog data is structured this way (in addition to the database model) for ease of managing specific files corresponding to catalogs or requirements lists; also, the files are statically served for the mobile apps. 6 | 7 | ## Structure 8 | 9 | The catalogs directory should be structured as follows: 10 | 11 | ``` 12 | - requirements 13 | - major1.reql 14 | - ... 15 | - sem-fall-2019 16 | - 1.txt 17 | - ... 18 | - sem-spring-2020 19 | - 1.txt 20 | - ... 21 | - ... 22 | - raw 23 | - sem-spring-2020 24 | - 1.txt 25 | - ... 26 | - ... 27 | - deltas 28 | - requirements 29 | - delta-1.txt 30 | - ... 31 | - sem-fall-2019 32 | - delta-1.txt 33 | - ... 34 | - sem-spring-2020 35 | - delta-1.txt 36 | - ... 37 | ``` 38 | 39 | The top-level directories contain the data files for `requirements` and semesters (prefixed by `sem-`). The `raw` directory contains intermediate files generated during catalog parse operations (see below). The `deltas` directory contains a directory named for each of those top-level directories, which contains the delta files for each version of the catalog. 40 | 41 | Each delta file is named `delta-[VERSION-NUMBER].txt` and formatted as follows (filenames are listed without extension): 42 | 43 | ``` 44 | [SEASON]#,#[YEAR] 45 | [VERSION NUMBER] 46 | [CHANGED FILENAME 1] 47 | [CHANGED FILENAME 2] 48 | ... 49 | ``` 50 | 51 | ## Update Scripts 52 | 53 | The course catalog update process is automated and can largely be managed through the web interface. However, because this is a singleton process that must occur outside a single server-client connection, it relies on the external script `update_catalog.py`, which is run as a cron job every few minutes on deployed servers. Similarly, the `update_db.py` script is scheduled to run daily during the early morning, to avoid performing database tasks during normal usage hours. 54 | 55 | You can run these scripts manually in the command line as well. First, set the `DJANGO_SETTINGS_MODULE` environment variable in your terminal: 56 | 57 | ``` 58 | export DJANGO_SETTINGS_MODULE="fireroad.settings" 59 | ``` 60 | 61 | Then, run the script as directed below. 62 | 63 | ### Catalog Updates 64 | 65 | On a local version, you can easily start this script manually by the command 66 | 67 | ``` 68 | python update_catalog.py [SEASON]-[YEAR] 69 | ``` 70 | 71 | where `[SEASON]-[YEAR]` specifies the current semester (e.g. `fall-2020` for the fall semester in the calendar year 2020 — note that this does not follow the internal convention of calling the semester `2021fa` because it is in the 2020-21 academic year). Once this script is started, you can navigate to the catalog update page in the UI to watch the progress and review the results. When it is finished, you will need to click Deploy Update. Finally, run the database update script: 72 | 73 | ``` 74 | python update_db.py 75 | ``` 76 | 77 | Note that each of these scripts may take several minutes to run. 78 | 79 | ### Requirements Updates 80 | 81 | Requirements list updating follows a similar process to the course catalog, except that updates are entered manually instead of scraped. Therefore, you can create and edit the requirements lists directly in the Requirements Editor, deploy them, and then run the database update script: 82 | 83 | ``` 84 | python update_db.py 85 | ``` 86 | -------------------------------------------------------------------------------- /common/token_gen.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import json 3 | import os 4 | from django.contrib.auth.models import User 5 | from django.conf import settings 6 | from django.utils.dateparse import parse_datetime 7 | from django.utils.timezone import is_aware, make_aware 8 | from django.utils import timezone 9 | import datetime 10 | from oauth_client import generate_random_string, LOGIN_TIMEOUT 11 | from .models import TemporaryCode, APIClient 12 | from django.core.exceptions import PermissionDenied 13 | 14 | FIREROAD_ISSUER = 'com.base12innovations.fireroad-server' 15 | 16 | def get_aware_datetime(date_str): 17 | ret = parse_datetime(date_str) 18 | if not is_aware(ret): 19 | ret = make_aware(ret) 20 | return ret 21 | 22 | def generate_token(request, user, expire_time, api_client=None): 23 | """Generates a JWT token for the given user that expires after the given 24 | number of seconds.""" 25 | expiry_date = str(timezone.now() + datetime.timedelta(seconds=expire_time)) 26 | # Specify which permissions this access token is allowed to authorize. 27 | # If no API client is given to this method, a universal permission is granted (this 28 | # corresponds to the mobile app use case!) 29 | payload = { 30 | 'username': user.username, 31 | 'permissions': (api_client.permissions_flag() if api_client else 32 | APIClient.universal_permission_flag()), 33 | 'iss': FIREROAD_ISSUER, 34 | 'expires': expiry_date 35 | } 36 | encoded = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') 37 | return encoded 38 | 39 | def extract_token_info(request, token): 40 | """Decodes the given JWT token and determines if it is valid. If so, returns 41 | the user associated with that token, an integer flag representing the permissions granted, 42 | and an error object of None. If not, returns None, None, and a dictionary explaining the error.""" 43 | try: 44 | payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) 45 | except: 46 | return None, None, {'error': 'decode_error', 'error_description': 'The token could not be decoded'} 47 | try: 48 | if payload['iss'] != FIREROAD_ISSUER: 49 | return None, None, {'error': 'invalid_issuer', 'error_description': 'The issuer of this token does not have the correct value'} 50 | date = get_aware_datetime(payload['expires']) 51 | if date < timezone.now(): 52 | return None, None, {'error': 'expired', 'error_description': 'The token has expired'} 53 | username = payload['username'] 54 | 55 | permissions = payload['permissions'] 56 | except KeyError: 57 | return None, None, {'error': 'incomplete_token', 'error_description': 'The token is missing one or more keys'} 58 | 59 | try: 60 | user = User.objects.get(username=username) 61 | except: 62 | return None, None, {'error': 'invalid_user', 'error_description': 'The token represents a non-existent user'} 63 | 64 | return user, permissions, None 65 | 66 | def save_temporary_code(access_info): 67 | """Generates, saves, and returns a temporary code associated with the 68 | given access information JSON object.""" 69 | 70 | code_storage = TemporaryCode.objects.create(access_info=json.dumps(access_info), code=generate_random_string(80)) 71 | code_storage.save() 72 | return code_storage.code 73 | 74 | def get_access_info_with_temporary_code(code): 75 | """Validates the given temporary code and retrieves the access info associated 76 | with it as a JSON object, deleting the code storage afterward. Raises 77 | PermissionDenied if the code is not found or is expired.""" 78 | 79 | try: 80 | code_storage = TemporaryCode.objects.get(code=code) 81 | expiry_date = code_storage.date + datetime.timedelta(seconds=LOGIN_TIMEOUT) 82 | if expiry_date < timezone.now(): 83 | raise PermissionDenied 84 | ret = json.loads(code_storage.access_info) 85 | code_storage.delete() 86 | return ret 87 | except: 88 | raise PermissionDenied 89 | -------------------------------------------------------------------------------- /requirements/static/requirements/css/req_preview.css: -------------------------------------------------------------------------------- 1 | #preview { 2 | overflow: scroll; 3 | padding: 0 6px 6px 6px; 4 | } 5 | 6 | #preview-loading-ind { 7 | position: absolute; 8 | margin: auto; 9 | left: 0; 10 | right: 0; 11 | top: 0; 12 | bottom: 0; 13 | } 14 | 15 | .req-title { 16 | margin-top: 4px !important; 17 | } 18 | 19 | h3.req-title { 20 | padding-left: 6px; 21 | font-size: 14pt; 22 | font-weight: 700; 23 | } 24 | 25 | h4.req-title { 26 | padding-left: 6px; 27 | font-size: 12pt; 28 | font-weight: 600; 29 | } 30 | 31 | h1.req-title { 32 | padding-left: 6px; 33 | font-size: 18pt; 34 | font-weight: 800; 35 | } 36 | 37 | h2.req-title { 38 | padding-left: 6px; 39 | font-size: 16pt; 40 | font-weight: 700; 41 | } 42 | 43 | p.req { 44 | padding-left: 6px; 45 | color: #888; 46 | } 47 | 48 | .course-list { 49 | overflow-x: auto; 50 | overflow-y: visible; 51 | white-space: nowrap; 52 | width: 100%; 53 | } 54 | 55 | .course-list-inner { 56 | display: table-row; 57 | overflow-y: visible; 58 | } 59 | 60 | .course-tile-outer { 61 | top: 0; 62 | overflow-y: visible; 63 | display: table-cell; 64 | padding: 2px 8px 2px 8px; 65 | } 66 | 67 | .course-tile { 68 | display: block; 69 | text-align: center; 70 | white-space: normal; 71 | overflow: hidden; 72 | padding: 6px; 73 | width: 140px; 74 | height: 76px; 75 | line-height: 1.15em; 76 | } 77 | 78 | .course-id { 79 | font-weight: 200; 80 | font-size: 13pt; 81 | } 82 | 83 | .course-title { 84 | font-weight: 400; 85 | font-size: 9pt; 86 | } 87 | 88 | /* Department classes */ 89 | 90 | .course-none { background-color: #999 !important; } 91 | 92 | .course-1 { background-color: #DE4343 !important; } 93 | .course-2 { background-color: #DE7643 !important; } 94 | .course-3 { background-color: #4369DE !important; } 95 | .course-4 { background-color: #57B563 !important; } 96 | .course-5 { background-color: #43DEAF !important; } 97 | .course-6 { background-color: #4390DE !important; } 98 | .course-7 { background-color: #5779B5 !important; } 99 | .course-8 { background-color: #8157B5 !important; } 100 | .course-9 { background-color: #8143DE !important; } 101 | .course-10 { background-color: #B55757 !important; } 102 | .course-11 { background-color: #B55773 !important; } 103 | .course-12 { background-color: #43DE4F !important; } 104 | .course-14 { background-color: #DE9043 !important; } 105 | .course-15 { background-color: #B55C57 !important; } 106 | .course-16 { background-color: #43B2DE !important; } 107 | .course-17 { background-color: #DE43B7 !important; } 108 | .course-18 { background-color: #575DB5 !important; } 109 | .course-20 { background-color: #57B56E !important; } 110 | .course-21 { background-color: #57B567 !important; } 111 | .course-21A { background-color: #57B573 !important; } 112 | .course-21W { background-color: #57B580 !important; } 113 | .course-CMS { background-color: #57B58C !important; } 114 | .course-21G { background-color: #57B599 !important; } 115 | .course-21H { background-color: #57B5A5 !important; } 116 | .course-21L { background-color: #57B5B2 !important; } 117 | .course-21M { background-color: #57ACB5 !important; } 118 | .course-WGS { background-color: #579FB5 !important; } 119 | .course-22 { background-color: #B55757 !important; } 120 | .course-24 { background-color: #7657B5 !important; } 121 | .course-CC { background-color: #4FDE43 !important; } 122 | .course-CSB { background-color: #579AB5 !important; } 123 | .course-EC { background-color: #76B557 !important; } 124 | .course-EM { background-color: #576EB5 !important; } 125 | .course-ES { background-color: #5A57B5 !important; } 126 | .course-HST { background-color: #5779B5 !important; } 127 | .course-IDS { background-color: #57B586 !important; } 128 | .course-MAS { background-color: #57B55A !important; } 129 | .course-SCM { background-color: #57B573 !important; } 130 | .course-STS { background-color: #8F57B5 !important; } 131 | .course-SWE { background-color: #B56B57 !important; } 132 | .course-SP { background-color: #4343DE !important; } 133 | -------------------------------------------------------------------------------- /sync/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.forms import ModelForm 4 | 5 | road_compressions = { 6 | ('"overrideWarnings":', '"ow":'), 7 | ('"semester":', '"sm":'), 8 | ('"title":', '"t":'), 9 | ('"units":', '"u":') 10 | } 11 | 12 | schedule_compressions = { 13 | ('"selectedSubjects":', '"ssub":'), 14 | ('"selectedSections":', '"ssec":'), 15 | ('"allowedSections":', '"as":'), 16 | } 17 | 18 | class Road(models.Model): 19 | user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) 20 | name = models.CharField(max_length=50) 21 | contents = models.TextField() 22 | modified_date = models.DateTimeField(auto_now=True) 23 | last_agent = models.CharField(max_length=50, default="") 24 | 25 | def __str__(self): 26 | return "{}: {}, last modified {}".format(self.user.username, self.name.encode('utf-8'), self.modified_date) 27 | 28 | @staticmethod 29 | def compress(road_text): 30 | road_text = road_text.replace("\n", "") 31 | road_text = road_text.replace("\t", "") 32 | road_text = road_text.replace('" : ', '":') 33 | for expr, sub in road_compressions: 34 | road_text = road_text.replace(expr, sub) 35 | return road_text 36 | 37 | @staticmethod 38 | def expand(road_text): 39 | for expr, sub in road_compressions: 40 | road_text = road_text.replace(sub, expr) 41 | return road_text 42 | 43 | class RoadForm(ModelForm): 44 | class Meta: 45 | model = Road 46 | fields = ['name', 'contents'] 47 | 48 | class RoadBackup(models.Model): 49 | """Represents a timestamped snapshot of a particular road.""" 50 | document = models.ForeignKey(Road, null=True, on_delete=models.CASCADE) 51 | timestamp = models.DateTimeField() 52 | last_agent = models.CharField(max_length=50, default="") 53 | name = models.CharField(max_length=50) 54 | contents = models.TextField() 55 | 56 | def __str__(self): 57 | return "Backup of {}, saved {} by {}".format(self.document.name.encode("utf-8") if self.document else "", 58 | self.timestamp, 59 | self.last_agent.encode("utf-8") if self.last_agent else "") 60 | 61 | class Schedule(models.Model): 62 | user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) 63 | name = models.CharField(max_length=50) 64 | contents = models.TextField() 65 | modified_date = models.DateTimeField(auto_now=True) 66 | last_agent = models.CharField(max_length=50, default="") 67 | 68 | def __str__(self): 69 | return "{}: {}, last modified {}".format(self.user.username, self.name.encode('utf-8'), self.modified_date) 70 | 71 | @staticmethod 72 | def compress(schedule_text): 73 | schedule_text = schedule_text.replace("\n", "") 74 | schedule_text = schedule_text.replace("\t", "") 75 | schedule_text = schedule_text.replace('" : ', '":') 76 | for expr, sub in schedule_compressions: 77 | schedule_text = schedule_text.replace(expr, sub) 78 | return schedule_text 79 | 80 | @staticmethod 81 | def expand(schedule_text): 82 | for expr, sub in schedule_compressions: 83 | schedule_text = schedule_text.replace(sub, expr) 84 | return schedule_text 85 | 86 | class ScheduleBackup(models.Model): 87 | """Represents a timestamped snapshot of a particular schedule.""" 88 | document = models.ForeignKey(Schedule, null=True, on_delete=models.CASCADE) 89 | timestamp = models.DateTimeField() 90 | last_agent = models.CharField(max_length=50, default="") 91 | name = models.CharField(max_length=50) 92 | contents = models.TextField() 93 | 94 | def __str__(self): 95 | return "Backup of {}, saved {} by {}".format(self.document.name.encode("utf-8") if self.document else "", 96 | self.timestamp, 97 | self.last_agent.encode("utf-8") if self.last_agent else "") 98 | 99 | -------------------------------------------------------------------------------- /courseupdater/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django import forms 3 | from catalog.models import Course 4 | 5 | # Create your models here. 6 | class CatalogUpdate(models.Model): 7 | """ 8 | Describes an update to the course catalog, and lists the current state. 9 | """ 10 | creation_date = models.DateTimeField(auto_now_add=True) 11 | semester = models.CharField(default="", max_length=30) 12 | progress = models.FloatField(default=0.0) 13 | progress_message = models.CharField(default="", max_length=50) 14 | is_completed = models.BooleanField(default=False) 15 | is_staged = models.BooleanField(default=False) 16 | is_started = models.BooleanField(default=False) 17 | 18 | # Options for the parse 19 | designate_virtual_status = models.BooleanField(default=False) 20 | 21 | def __str__(self): 22 | base = "Catalog update for {} on {}".format(self.semester, self.creation_date) 23 | if self.is_completed: 24 | base += " (completed)" 25 | elif self.is_staged: 26 | base += " (staged)" 27 | elif self.is_started: 28 | base += " ({:.2f}% complete - {})".format(self.progress, self.progress_message) 29 | return base 30 | 31 | class CatalogUpdateStartForm(forms.Form): 32 | semester = forms.CharField(label='Semester', max_length=30, widget=forms.TextInput(attrs={'class': 'input-field', 'placeholder': 'e.g. fall-2019'})) 33 | designate_virtual_status = forms.BooleanField(label='Designate subject virtual status', 34 | widget=forms.CheckboxInput(attrs={'class': 35 | 'filled-in'}), 36 | initial=False, 37 | required=False) 38 | 39 | def clean_semester(self): 40 | """ 41 | Ensures that the entered semester is of the form 'season-year'. 42 | """ 43 | semester = self.cleaned_data['semester'] 44 | comps = semester.split('-') 45 | if len(comps) != 2: 46 | raise forms.ValidationError("Semester must be in the format 'season-year'.") 47 | season, year = comps 48 | if season not in ('fall', 'spring'): 49 | raise forms.ValidationError("Season must be fall or spring.") 50 | try: 51 | year = int(year) 52 | except: 53 | raise forms.ValidationError("Year should be a number.") 54 | else: 55 | if year < 2000 or year > 3000: 56 | raise forms.ValidationError("Invalid year.") 57 | return semester 58 | 59 | 60 | class CatalogUpdateDeployForm(forms.Form): 61 | pass 62 | 63 | class CatalogCorrection(Course): 64 | date_added = models.DateTimeField(auto_now_add=True) 65 | author = models.CharField(max_length=25, null=True) 66 | 67 | def __str__(self): 68 | return "Correction to {} by {} on {}".format(self.subject_id, self.author, self.date_added) 69 | 70 | class CatalogCorrectionForm(forms.ModelForm): 71 | 72 | class Meta: 73 | model = CatalogCorrection 74 | fields = ["subject_id", "title", "parent", "children", "description", "instructors", "gir_attribute", "communication_requirement", "hass_attribute", "equivalent_subjects", "old_id", "total_units", "lecture_units", "lab_units", "preparation_units", "design_units", "offered_fall", "offered_IAP", "offered_spring", "offered_summer", "is_variable_units", "is_half_class"] 75 | labels = { 76 | "gir_attribute": "GIR Attribute (e.g. PHY1, REST)", 77 | "communication_requirement": "Communication Requirement (e.g. CI-H)", 78 | "hass_attribute": "HASS Attribute (comma-separated)", 79 | "is_variable_units": "Variable units", 80 | "is_half_class": "Half class", 81 | "old_id": "Old subject ID" 82 | } 83 | widgets = { 84 | "description": forms.TextInput, 85 | "instructors": forms.TextInput, 86 | "offered_fall": forms.CheckboxInput, 87 | "offered_IAP": forms.CheckboxInput, 88 | "offered_spring": forms.CheckboxInput, 89 | "offered_summer": forms.CheckboxInput, 90 | } 91 | -------------------------------------------------------------------------------- /requirements/static/requirements/css/nav.css: -------------------------------------------------------------------------------- 1 | .nav-side-menu { 2 | overflow: auto; 3 | font-size: 14px; 4 | font-weight: 300; 5 | background-color: white; 6 | position: fixed; 7 | top: 0; 8 | width: 300px; 9 | color: #bb2222; 10 | border-right: 1px solid #dddddd; 11 | } 12 | .nav-side-menu .brand { 13 | background-color: white; 14 | line-height: 50px; 15 | display: block; 16 | text-align: center; 17 | font-size: 16px; 18 | font-weight: 500; 19 | } 20 | .nav-side-menu .toggle-btn { 21 | display: none; 22 | } 23 | .nav-side-menu ul, 24 | .nav-side-menu li { 25 | list-style: none; 26 | margin: 0px; 27 | /*line-height: 44px;*/ 28 | cursor: pointer; 29 | } 30 | .nav-side-menu ul { 31 | padding: 0; 32 | } 33 | .nav-side-menu li { 34 | padding: 8px 0 8px 0; 35 | } 36 | .nav-side-menu ul :not(collapsed) .arrow:before, 37 | .nav-side-menu li :not(collapsed) .arrow:before { 38 | font-family: FontAwesome; 39 | content: "\f078"; 40 | display: inline-block; 41 | padding-left: 10px; 42 | padding-right: 10px; 43 | vertical-align: middle; 44 | float: right; 45 | } 46 | .nav-side-menu ul .active, 47 | .nav-side-menu li .active { 48 | border-left: 3px solid #f54c3b; 49 | background-color: white; 50 | } 51 | .nav-side-menu ul .sub-menu li.active, 52 | .nav-side-menu li .sub-menu li.active { 53 | color: #f54c3b; 54 | } 55 | .nav-side-menu ul .sub-menu li.active a, 56 | .nav-side-menu li .sub-menu li.active a { 57 | color: #f54c3b; 58 | } 59 | .nav-side-menu ul .sub-menu li, 60 | .nav-side-menu li .sub-menu li { 61 | background-color: white; 62 | border: none; 63 | /*line-height: 32px;*/ 64 | border-bottom: 1px solid #dddddd; 65 | margin-left: 0px; 66 | } 67 | .nav-side-menu ul .sub-menu li:hover, 68 | .nav-side-menu li .sub-menu li:hover { 69 | background-color: #ffddd9; 70 | } 71 | /*.nav-side-menu ul .sub-menu li:before, 72 | .nav-side-menu li .sub-menu li:before { 73 | font-family: FontAwesome; 74 | content: "\f105"; 75 | display: inline-block; 76 | padding-left: 10px; 77 | padding-right: 10px; 78 | vertical-align: middle; 79 | }*/ 80 | .nav-side-menu li { 81 | padding-left: 0px; 82 | display: block; 83 | border-left: 3px solid #dddddd; 84 | border-bottom: 1px solid #dddddd; 85 | } 86 | .nav-side-menu li a { 87 | text-decoration: none; 88 | padding-left: 30px; 89 | display: block; 90 | color: #bb2222; 91 | -webkit-box-decoration-break: clone; 92 | box-decoration-break: clone; 93 | } 94 | .nav-side-menu li a i { 95 | padding-left: 10px; 96 | width: 20px; 97 | padding-right: 20px; 98 | } 99 | .nav-side-menu li:hover { 100 | border-left: 3px solid #f45a48; 101 | background-color: #ffddd9; 102 | -webkit-transition: all 1s ease; 103 | -moz-transition: all 1s ease; 104 | -o-transition: all 1s ease; 105 | -ms-transition: all 1s ease; 106 | transition: all 1s ease; 107 | } 108 | @media (max-width: 767px) { 109 | .nav-side-menu { 110 | position: relative; 111 | width: 100%; 112 | margin-bottom: 10px; 113 | } 114 | .nav-side-menu .toggle-btn { 115 | display: block; 116 | cursor: pointer; 117 | position: absolute; 118 | right: 10px; 119 | top: 10px; 120 | z-index: 10 !important; 121 | padding: 3px; 122 | background-color: #ffffff; 123 | color: #ddd; 124 | width: 40px; 125 | text-align: center; 126 | } 127 | .brand { 128 | text-align: left !important; 129 | font-size: 24px; 130 | padding-left: 20px; 131 | line-height: 50px !important; 132 | } 133 | .content { 134 | margin-left: 0; 135 | padding: 0 16px 0 16px; 136 | width: 100%; 137 | } 138 | } 139 | @media (min-width: 767px) { 140 | .nav-side-menu { 141 | height: 100%; 142 | padding-top: 64px; 143 | } 144 | .nav-side-menu .menu-list .menu-content { 145 | display: block; 146 | } 147 | .content { 148 | padding-left: 300px; 149 | } 150 | } 151 | body { 152 | margin: 0px; 153 | padding: 0px; 154 | } 155 | .row { 156 | margin-left: 0 !important; 157 | margin-right: 0 !important; 158 | } 159 | .collapse { 160 | display: none 161 | } 162 | 163 | .collapse.in { 164 | display: block 165 | } 166 | 167 | tr.collapse.in { 168 | display: table-row 169 | } 170 | 171 | tbody.collapse.in { 172 | display: table-row-group 173 | } 174 | 175 | .collapsing { 176 | position: relative; 177 | height: 0; 178 | overflow: hidden; 179 | -webkit-transition: height .35s ease; 180 | -o-transition: height .35s ease; 181 | transition: height .35s ease 182 | } 183 | -------------------------------------------------------------------------------- /catalog_parse/delta_gen.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This script will compare the files in the two provided directories (if the old 3 | catalog exists) and produce a delta file that enumerates which files have changed. 4 | This file will be saved in the appropriate directory within server_path (a variable 5 | set at the top of this script). The version number is automatically detected based 6 | on which delta files are already present in this location. 7 | ''' 8 | 9 | import os 10 | import sys 11 | import shutil 12 | 13 | semester_prefix = "sem-" 14 | delta_prefix = "delta-" 15 | delta_separator = "#,#" 16 | requirements_dir_name = "requirements" 17 | excluded_file_names = ["features.txt"] 18 | 19 | # These file names will be concatenated to the delta file if the version number is 1. 20 | first_version_file_names = ["departments", "enrollment"] 21 | 22 | def write_delta_file(semester_name, delta, outpath): 23 | # Determine version number 24 | version_num = 1 25 | if os.path.exists(outpath): 26 | while os.path.exists(os.path.join(outpath, delta_prefix + str(version_num) + ".txt")): 27 | version_num += 1 28 | else: 29 | os.mkdir(outpath) 30 | if version_num == 1: 31 | delta = delta + first_version_file_names 32 | 33 | comps = semester_name.split('-') 34 | delta_file_path = os.path.join(outpath, delta_prefix + str(version_num) + ".txt") 35 | with open(delta_file_path, 'w') as file: 36 | if semester_name != requirements_dir_name: 37 | file.write(delta_separator.join(comps) + "\n") 38 | else: 39 | file.write("\n") 40 | file.write(str(version_num) + "\n") 41 | file.write("\n".join(delta)) 42 | 43 | print("Delta file written to {}.".format(delta_file_path)) 44 | 45 | def delta_file_name(path): 46 | if ".txt" in path: 47 | return path[:path.find(".txt")] 48 | elif ".reql" in path: 49 | return path[:path.find(".reql")] 50 | return path 51 | 52 | def make_delta(new_directory, old_directory): 53 | """Computes a list of file names that have changed between the old directory 54 | and the new directory.""" 55 | delta = [] 56 | for path in os.listdir(new_directory): 57 | if path[0] == '.' or path in excluded_file_names: continue 58 | old_path = os.path.join(old_directory, path) 59 | if not os.path.exists(old_path): 60 | delta.append(delta_file_name(path)) 61 | continue 62 | with open(os.path.join(new_directory, path), 'r') as new_file: 63 | with open(old_path, 'r') as old_file: 64 | new_lines = new_file.readlines() 65 | old_lines = old_file.readlines() 66 | if new_lines != old_lines: 67 | delta.append(delta_file_name(path)) 68 | return delta 69 | 70 | def commit_delta(new_directory, old_directory, server_path, delta): 71 | """Writes the delta to file, and moves the contents of new_directory into 72 | old_directory (preserving the old contents in an '-old' directory).""" 73 | 74 | old_name = os.path.basename(old_directory) 75 | if semester_prefix in old_name: 76 | semester_name = old_name[old_name.find(semester_prefix) + len(semester_prefix):] 77 | else: 78 | semester_name = old_name 79 | 80 | write_delta_file(semester_name, delta, os.path.join(server_path, old_name)) 81 | old_dest = os.path.join(os.path.dirname(old_directory), old_name + "-old") 82 | if os.path.exists(old_dest): 83 | shutil.rmtree(old_dest) 84 | if os.path.exists(old_directory): 85 | shutil.move(old_directory, old_dest) 86 | shutil.move(new_directory, old_directory) 87 | 88 | if __name__ == '__main__': 89 | if len(sys.argv) < 4: 90 | print("Insufficient arguments. Pass the directory of new files, the directory that the files should be saved to (e.g. sem-spring-2018), and the path to the courseupdater directory in the server. Make sure you do not add trailing slashes to your paths.") 91 | exit(1) 92 | new_directory = sys.argv[1] 93 | old_directory = sys.argv[2] 94 | server_path = sys.argv[3] 95 | print("Changed items:") 96 | delta = make_delta(new_directory, old_directory) 97 | for file in delta: 98 | print(file) 99 | if raw_input("Ready to write files?") in ['y', 'yes', '\n']: 100 | commit_delta(new_directory, old_directory, server_path, delta) 101 | print("Old files moved to {}. New files moved to {}.".format(os.path.join(os.path.dirname(old_directory), old_name + "-old"), old_directory)) 102 | else: 103 | print("Aborting.") 104 | -------------------------------------------------------------------------------- /common/oauth_client.py: -------------------------------------------------------------------------------- 1 | from .models import * 2 | import json 3 | from django.core.exceptions import PermissionDenied 4 | import requests 5 | import os 6 | import base64 7 | import urllib 8 | from .models import OAuthCache 9 | import random 10 | from django.utils import timezone 11 | from django.conf import settings 12 | 13 | module_path = os.path.dirname(__file__) 14 | 15 | REDIRECT_URI = settings.MY_BASE_URL + '/login/' 16 | ISSUER = 'https://oidc.mit.edu/' 17 | AUTH_CODE_URL = 'https://oidc.mit.edu/authorize' 18 | AUTH_TOKEN_URL = 'https://oidc.mit.edu/token' 19 | AUTH_USER_INFO_URL = 'https://oidc.mit.edu/userinfo' 20 | 21 | LOGIN_TIMEOUT = 600 22 | AUTH_SCOPES = ['email', 'openid', 'profile', 'offline_access'] 23 | AUTH_RESPONSE_TYPE = 'code' 24 | 25 | def get_client_info(): 26 | with open(os.path.join(module_path, 'oidc.txt'), 'r') as file: 27 | contents = file.read().strip() 28 | id, secret = contents.split('\n') 29 | return id, secret 30 | 31 | def generate_random_string(length): 32 | choices = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 33 | return ''.join(random.choice(choices) for _ in range(length)) 34 | 35 | def oauth_code_url(request, after_redirect=None): 36 | """after_redirect is used to redirect to an application site with a 37 | temporary code AFTER FireRoad has created the user's account. It should be 38 | None for mobile apps and a string for websites.""" 39 | 40 | # Create a state and nonce, and save them 41 | cache = OAuthCache(state=generate_random_string(48), nonce=generate_random_string(48), redirect_uri=after_redirect) 42 | sem = request.GET.get('sem', '') 43 | if len(sem) > 0: 44 | cache.current_semester = sem 45 | cache.save() 46 | return "{}?response_type={}&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}".format( 47 | AUTH_CODE_URL, 48 | AUTH_RESPONSE_TYPE, 49 | get_client_info()[0], 50 | urllib.quote(REDIRECT_URI), 51 | urllib.quote(' '.join(AUTH_SCOPES)), 52 | cache.state, 53 | cache.nonce) 54 | 55 | def get_user_info(request): 56 | code = request.GET.get('code', None) 57 | state = request.GET.get('state', None) 58 | 59 | caches = OAuthCache.objects.filter(state=state) 60 | if caches.count() == 0: 61 | raise PermissionDenied 62 | 63 | acc_token, info, all_json, status = get_oauth_id_token(request, code, state) 64 | if acc_token is None: 65 | return None, status, None 66 | 67 | result, status = get_user_info_with_token(request, acc_token) 68 | if result is not None: 69 | if "refresh_token" in all_json: 70 | result[u'refresh_token'] = all_json["refresh_token"] 71 | return result, status, info 72 | 73 | def get_oauth_id_token(request, code, state, refresh=False): 74 | id, secret = get_client_info() 75 | 76 | if refresh: 77 | payload = { 78 | 'grant_type': 'refresh_token', 79 | 'refresh_token': code 80 | } 81 | else: 82 | payload = { 83 | 'grant_type': 'authorization_code', 84 | 'code': code, 85 | 'redirect_uri': REDIRECT_URI 86 | } 87 | r = requests.post(AUTH_TOKEN_URL, auth=(id, secret), data=payload) 88 | if r.status_code != 200: 89 | return None, None, None, r.status_code 90 | 91 | # Parse token 92 | r_json = r.json() 93 | 94 | id_token = r_json['id_token'] 95 | header, body, signature = id_token.split('.') 96 | header_text = base64.b64decode(header) 97 | body += "=" * ((4 - len(body) % 4) % 4) 98 | body_text = base64.b64decode(body) 99 | 100 | body = json.loads(body_text) 101 | if body['iss'] != ISSUER: 102 | raise PermissionDenied 103 | nonce = body['nonce'] 104 | caches = OAuthCache.objects.filter(state=state) 105 | found = False 106 | info = {} 107 | for cache in caches: 108 | if cache.nonce == nonce: 109 | current_date = timezone.now() 110 | if (current_date - cache.date).total_seconds() > LOGIN_TIMEOUT: 111 | return None, None, None, 408 112 | found = True 113 | info["sem"] = cache.current_semester 114 | if cache.redirect_uri is not None: 115 | info["redirect"] = cache.redirect_uri 116 | break 117 | if not found: 118 | raise PermissionDenied 119 | caches.delete() 120 | 121 | access_token = r_json["access_token"] 122 | return access_token, info, r_json, r.status_code 123 | 124 | def get_user_info_with_token(request, acc_token): 125 | headers = {"Authorization":"Bearer {}".format(acc_token)} 126 | r = requests.get(AUTH_USER_INFO_URL, headers=headers) 127 | return r.json(), r.status_code 128 | -------------------------------------------------------------------------------- /sync/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.http import HttpResponse, HttpResponseBadRequest 3 | from django.views.decorators.csrf import csrf_exempt 4 | from .models import * 5 | from common.models import * 6 | import random 7 | from common.decorators import logged_in_or_basicauth, require_token_permissions 8 | import json 9 | from django.utils import timezone 10 | from dateutil.relativedelta import relativedelta 11 | from dateutil.parser import parse 12 | from django.core.exceptions import ObjectDoesNotExist 13 | from .operations import * 14 | 15 | def get_datetime(form_contents, key): 16 | raw = form_contents.get(key, '') 17 | if len(raw) == 0: 18 | return None 19 | return parse(raw) 20 | 21 | def get_operation(request): 22 | """Returns a SyncOperation from the given request and None, or None and an 23 | HttpResponse error object.""" 24 | try: 25 | form_contents = json.loads(request.body) 26 | except: 27 | return None, HttpResponseBadRequest('

    Invalid request

    ') 28 | 29 | # Extract POST contents 30 | 31 | id = form_contents.get('id', 0) 32 | if id is None or id == 0: 33 | id = NEW_FILE_ID 34 | else: 35 | try: 36 | id = int(id) 37 | except ValueError: 38 | return HttpResponseBadRequest('

    Invalid ID

    ') 39 | 40 | contents = form_contents.get('contents', {}) 41 | if contents is None or len(contents) == 0: 42 | return None, HttpResponseBadRequest('

    Missing contents

    ') 43 | 44 | change_date = get_datetime(form_contents, 'changed') 45 | if change_date is None: 46 | return None, HttpResponseBadRequest('

    Missing or invalid changed date

    ') 47 | 48 | down_date = get_datetime(form_contents, 'downloaded') 49 | name = form_contents.get('name', '') 50 | agent = form_contents.get('agent', ANONYMOUS_AGENT) 51 | override = form_contents.get('override', False) 52 | 53 | return SyncOperation(id, name, contents, change_date, down_date, agent, override_conflict=override), None 54 | 55 | def delete_helper(request, model_cls): 56 | try: 57 | form_contents = json.loads(request.body) 58 | except: 59 | return None, HttpResponseBadRequest('

    Invalid request

    ') 60 | 61 | # Extract POST contents 62 | 63 | id = form_contents.get('id', 0) 64 | if id is None or id == 0: 65 | return HttpResponseBadRequest('

    Missing file ID

    ') 66 | try: 67 | id = int(id) 68 | except ValueError: 69 | return HttpResponseBadRequest('

    Invalid file ID

    ') 70 | 71 | resp = delete(request, model_cls, id) 72 | return HttpResponse(json.dumps(resp), content_type="application/json") 73 | 74 | @csrf_exempt 75 | @logged_in_or_basicauth 76 | @require_token_permissions("can_edit_roads") 77 | def sync_road(request): 78 | operation, err_resp = get_operation(request) 79 | if operation is None: 80 | return err_resp 81 | 82 | resp = sync(request, Road, operation) 83 | return HttpResponse(json.dumps(resp), content_type="application/json") 84 | 85 | @logged_in_or_basicauth 86 | @require_token_permissions("can_view_roads") 87 | def roads(request): 88 | road_id = request.GET.get('id', None) 89 | 90 | if road_id is not None: 91 | try: 92 | road_id = int(road_id) 93 | except ValueError: 94 | return HttpResponseBadRequest('

    Invalid road ID

    ') 95 | 96 | resp = browse(request, Road, road_id) 97 | return HttpResponse(json.dumps(resp), content_type="application/json") 98 | 99 | @csrf_exempt 100 | @logged_in_or_basicauth 101 | @require_token_permissions("can_delete_roads") 102 | def delete_road(request): 103 | return delete_helper(request, Road) 104 | 105 | @csrf_exempt 106 | @logged_in_or_basicauth 107 | @require_token_permissions("can_edit_schedules") 108 | def sync_schedule(request): 109 | operation, err_resp = get_operation(request) 110 | if operation is None: 111 | return err_resp 112 | 113 | resp = sync(request, Schedule, operation) 114 | return HttpResponse(json.dumps(resp), content_type="application/json") 115 | 116 | @logged_in_or_basicauth 117 | @require_token_permissions("can_view_schedules") 118 | def schedules(request): 119 | schedule_id = request.GET.get('id', None) 120 | 121 | if schedule_id is not None: 122 | try: 123 | schedule_id = int(schedule_id) 124 | except ValueError: 125 | return HttpResponseBadRequest('

    Invalid schedule ID

    ') 126 | 127 | resp = browse(request, Schedule, schedule_id) 128 | return HttpResponse(json.dumps(resp), content_type="application/json") 129 | 130 | @csrf_exempt 131 | @logged_in_or_basicauth 132 | @require_token_permissions("can_delete_schedules") 133 | def delete_schedule(request): 134 | return delete_helper(request, Schedule) 135 | -------------------------------------------------------------------------------- /requirements/templates/requirements/review_all.html: -------------------------------------------------------------------------------- 1 | {% extends "requirements/base.html" %} 2 | {% block title %} 3 | Review Edit Requests 4 | {% endblock %} 5 | {% block content %} 6 |
    7 |
    8 |

    Review Edit Requests

    9 | {% if deployments > 0 %} 10 |

    {{ deployments }} deployment{% if deployments != 1 %}s{% endif %} pending execution at the next database refresh.

    11 | {% endif %} 12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    Pending Review
    18 | {% if pending %} 19 |
    20 | {% for edit_req in pending %} 21 |
  • 22 | more_horiz 23 | 24 | {{ edit_req.list_id }}
    25 | {{ edit_req.email_address }}: {{ edit_req.reason }} 26 |
    27 | 28 | 33 |
  • 34 | {% endfor %} 35 |
    36 | {% else %} 37 |

    No pending edit requests!

    38 | {% endif %} 39 |
    40 |
    41 |
    Committed
    42 | {% if committed %} 43 |
    44 | {% for edit_req in committed %} 45 |
  • 46 | Uncommit 47 | 48 | {{ edit_req.list_id }}
    49 | {{ edit_req.email_address }}: {{ edit_req.reason }} 50 |
    51 | 52 | 56 |
  • 57 | {% endfor %} 58 |
    59 | {% else %} 60 |

    No changes committed yet.

    61 | {% endif %} 62 |
    63 |
    64 |
    65 |
    66 |

    Be sure all edit requests are correctly formatted, and add a descriptive change summary before deploying. 67 | {% if conflicts > 0 %} 68 |
    You have committed {{ conflicts }} edit request{% if conflicts != 1 %}s{% endif %} that will overwrite a previous pending deployment. 69 | {% endif %} 70 |

    71 |
    72 | {% csrf_token %} 73 |
    74 | {{ form.email_address }} 75 | {{ form.email_address.errors }} 76 |
    77 |
    78 | {% if num_to_deploy > 0 %} 79 | 82 | {% else %} 83 | 84 | {% endif %} 85 |
    86 | {{ form.summary }} 87 | {{ form.summary.errors }} 88 | 89 | 90 |
    91 |
    92 | {{ form.non_field_errors }} 93 |
    94 |
    95 | {% endblock %} 96 | {% block scripts %} 97 | 104 | {% endblock %} 105 | -------------------------------------------------------------------------------- /requirements/templates/requirements/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "requirements/base.html" %} 2 | {% block title %} 3 | {{ action }} {{ req.medium_title }} 4 | {% endblock %} 5 | {% block content %} 6 |
    7 |
    8 |

    {{ action }} {{ req.medium_title }}

    9 |
    10 | Preview 11 |
    12 |
    13 | 14 | 15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    28 | {% if not is_staff %} 29 |

    {% if action == "Create" %}To submit a new requirements list, please enter the requirements in the box above{% else %}To submit a modification request, please identify and/or correct the appropriate location in the above requirements definition{% endif %} (following the rules described in the Format Specification). Then provide your email address (in case of any clarifications) and a brief message explaining the change, and press Submit.

    30 |
    31 | {% csrf_token %} 32 |
    33 | {{ form.is_committing.as_hidden }} 34 | {{ form.email_address }} 35 | 36 | {{ form.email_address.errors }} 37 |
    38 | {% if action == "Create" %} 39 |
    40 | {{ form.new_list_id }} 41 | {{ form.new_list_id.errors }} 42 |
    43 |
    44 | {% else %} 45 | {{ form.new_list_id.as_hidden }} 46 |
    47 | {% endif %} 48 | 51 |
    52 | {{ form.reason }} 53 | {{ form.reason.errors }} 54 | 55 | 56 |
    57 |
    58 | {{ form.non_field_errors }} 59 | {{ form.contents }} 60 | 61 | {% else %} 62 | {% if action == "Create" %} 63 |
    64 | {% else %} 65 |
    66 | {% endif %} 67 |

    Click the Commit button below to save your changes. Once you have committed all necessary requirements lists, visit the Review page and click Deploy to apply the changes at the next database refresh.

    68 |
    69 | {% if action == "Create" %} 70 |
    71 |
    72 | {{ form.new_list_id }} 73 | {{ form.new_list_id.errors }} 74 |
    75 |
    76 | {% else %} 77 | 78 | {{ form.new_list_id.as_hidden }} 79 |
    80 | {% endif %} 81 | {% csrf_token %} 82 | {{ form.is_committing.as_hidden }} 83 | {{ form.email_address.as_hidden }} 84 | {{ form.contents }} 85 | {{ form.reason.as_hidden }} 86 | 87 | Reset 88 |
    89 | {{ form.non_field_errors }} 90 | 91 | {% endif %} 92 | 93 |
    94 | {% endblock %} 95 | {% block scripts %} 96 | 112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /privacy_policy.md: -------------------------------------------------------------------------------- 1 | # FireRoad Mobile App Privacy Policy 2 | 3 | FireRoad Dev Team built the FireRoad app as a Free app. This SERVICE is provided by FireRoad Dev Team at no cost and is intended for use as is. 4 | 5 | This page is used to inform visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our Service. 6 | 7 | If you choose to use our Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that we collect is used for providing and improving the Service. We will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which are accessible at FireRoad unless otherwise defined in this Privacy Policy. 10 | 11 | **Information Collection and Use** 12 | 13 | For a better experience, while using our Service, we may require you to provide us with certain personally identifiable information, including but not limited to your name and MIT email address. The information that we request will be retained by us and used as described in this privacy policy. 14 | 15 | **Log Data** 16 | 17 | We want to inform you that whenever you use our Service, in a case of an error in the app we collect data and information (through third-party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing our Service, the time and date of your use of the Service, and other statistics. 18 | 19 | **Cookies** 20 | 21 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 22 | 23 | This Service does not use these “cookies” explicitly. However, the app may use third-party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 24 | 25 | **Service Providers** 26 | 27 | We may employ third-party companies and individuals due to the following reasons: 28 | 29 | * To facilitate our Service; 30 | * To provide the Service on our behalf; 31 | * To perform Service-related services; or 32 | * To assist us in analyzing how our Service is used. 33 | 34 | We want to inform users of this Service that these third parties have access to their Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 35 | 36 | **Security** 37 | 38 | We value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and we cannot guarantee its absolute security. 39 | 40 | **Links to Other Sites** 41 | 42 | This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 43 | 44 | **Children’s Privacy** 45 | 46 | We do not knowingly collect personally identifiable information from children. We encourage all children to never submit any personally identifiable information through the Application and/or Services. We encourage parents and legal guardians to monitor their children's Internet usage and to help enforce this Policy by instructing their children never to provide personally identifiable information through the Application and/or Services without their permission. If you have reason to believe that a child has provided personally identifiable information to us through the Application and/or Services, please contact us. You must also be at least 16 years of age to consent to the processing of your personally identifiable information in your country (in some countries we may allow your parent or guardian to do so on your behalf). 47 | 48 | **Changes to This Privacy Policy** 49 | 50 | We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. 51 | 52 | This policy is effective as of 2022-08-25 53 | 54 | **Contact Us** 55 | 56 | If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us at fireroad-dev@mit.edu. 57 | 58 | This privacy policy page was created at [privacypolicytemplate.net](https://privacypolicytemplate.net) and modified/generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.nisrulz.com/) 59 | -------------------------------------------------------------------------------- /common/templates/common/docs/auth.html: -------------------------------------------------------------------------------- 1 | {% extends "common/docs/base.html" %} 2 | 3 | {% block nav %} 4 | {% include "common/docs/sidebar.html" with active_id="auth" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

    Authentication

    9 | 10 |

    Login Procedures

    11 | 12 | Please remember to use the dev server (fireroad-dev.mit.edu) for all local testing, and only use the production server (fireroad.mit.edu) for your production application. Use of the production server requires prior approval by the FireRoad team (see "Approval" below). The workflow for logging into the FireRoad server as a web application is as follows: 13 | 14 |
      15 |
    1. Your site sends the user to /login, with an optional query parameter sem indicating the user's current semester, and required query parameter redirect indicating the redirect URL after login. For production, this redirect URL needs to be registered with FireRoad before use.
    2. 16 |
    3. The FireRoad server handles login through Touchstone, creates a FireRoad account if necessary, then sends the user back to your redirect URL, passing a query parameter code
    4. 17 |
    5. The code is a string that can be used exactly once within 5 minutes of login to retrieve an access token. The application server does this by sending a request to /fetch_token, passing the query parameter code received in step 2.
    6. 18 |
    7. The FireRoad server validates the temporary code and sends the application server back a JSON web token (JWT) that can be used to authorize use of the API.
    8. 19 |
    9. The application server uses the JWT by including the Authorization header set to "Bearer <base-64 encoded token string>" in any request to the FireRoad server.
    10. 20 |
    11. Since the JWT may expire, the application server should check its validity by requesting the /verify endpoint with the Authorization header. If the token is expired or invalid, this endpoint will return 403, indicating that the user should log in again.
    12. 21 |
    22 |
    23 | 24 |

    Approval

    25 | 26 | To use the FireRoad production server, you must contact the FireRoad development team with the following information: 27 | 28 |
      29 |
    • The name and description of your service. The name will be displayed to users when asked to approve access to your application.
    • 30 |
    • A name and email address for the point of contact for the application.
    • 31 |
    • The permissions that your application needs to function correctly (specifying whether read or read/write access is needed). When FireRoad issues your application an access token, that token will only be usable for the permissions you specify.
    • 32 |
    33 | 34 | When your application is approved, you will be able to authenticate users at the production endpoint. 35 | 36 |

    Endpoints

    37 | 38 |
    /signup
    39 |

    Displays a user-facing page that specifies the conditions of allowing recommendations.

    40 | 41 |
    /login
    42 |

    Redirects to the OAuth page to log the user in. See "Login Procedures" for how to log in as a web client. Note: Web clients must include a redirect query parameter. Requests without a redirect parameter will be treated as coming from a native (mobile) app, and will transfer the token to the client in a way that is not secure outside of a native app.

    43 | 44 |
    /fetch_token (GET)
    45 |

    Takes a query parameter code and, if it is valid and unexpired, returns the associated access token. See "Login Procedures" above for more details.

    46 | 47 |
    /verify (GET)
    48 |

    Checks that the user is logged in, and if so, auto-increments the user's current semester and returns the new semester.

    49 | 50 |
    /user_info (GET)
    51 |

    (Requires authentication) Returns a JSON object containing information about the current user, including the following keys:

    52 |
      53 |
    • academic_id: the user's institution email address
    • 54 |
    • current_semester: the user's current semester (numbered 0)
    • 55 |
    • name: the user's full name
    • 56 |
    • username the user's username on the FireRoad server (not human-readable)
    • 57 |
    58 | 59 |
    60 | 63 |
    64 |
    65 | Sync chevron_right 66 |
    67 |
    68 |

    69 |
    70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /fireroad/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base Django settings for fireroad project. These defaults are set up to work 3 | with the development server (which runs locally with python manage.py 4 | runserver), and are imported and partially overwritten by settings_dev and 5 | settings_prod. 6 | """ 7 | 8 | import os 9 | 10 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 11 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 12 | 13 | # Unzip the catalog data into the data directory 14 | CATALOG_BASE_DIR = "data/catalogs" 15 | 16 | # Use the Django default login page for local debugging 17 | LOGIN_URL = "/dev_login" 18 | 19 | # Security settings 20 | 21 | # If True, login redirects will be required to be registered as a RedirectURL 22 | # Set to True in production! 23 | RESTRICT_AUTH_REDIRECTS = False 24 | 25 | with open(os.path.join(os.path.dirname(__file__), 'secret.txt')) as f: 26 | SECRET_KEY = f.read().strip() 27 | 28 | DEBUG = True 29 | 30 | # Constructing URLs 31 | 32 | ALLOWED_HOSTS = ['localhost', 'lvh.me'] 33 | 34 | MY_BASE_URL = 'https://lvh.me:8000' 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.messages', 44 | 'django.contrib.staticfiles', 45 | 'django.contrib.admindocs', 46 | 'recommend', 47 | 'common', 48 | 'sync', 49 | 'requirements', 50 | 'courseupdater', 51 | 'catalog', 52 | 'analytics' 53 | ] 54 | 55 | MIDDLEWARE_CLASSES = [ 56 | # Cors middleware should only be on local development (not settings_dev or settings_prod) 57 | 'middleware.cors.CorsMiddleware', 58 | 'django.contrib.sessions.middleware.SessionMiddleware', 59 | 'django.middleware.common.CommonMiddleware', 60 | 'django.middleware.csrf.CsrfViewMiddleware', 61 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 62 | 'django.contrib.messages.middleware.MessageMiddleware', 63 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 64 | 'analytics.request_counter.RequestCounterMiddleware' 65 | ] 66 | 67 | AUTHENTICATION_BACKENDS = [ 68 | 'django.contrib.auth.backends.ModelBackend' 69 | ] 70 | 71 | ROOT_URLCONF = 'fireroad.urls' 72 | 73 | TEMPLATES = [ 74 | { 75 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 76 | 'DIRS': [], 77 | 'APP_DIRS': True, 78 | 'OPTIONS': { 79 | 'context_processors': [ 80 | 'django.template.context_processors.debug', 81 | 'django.template.context_processors.request', 82 | 'django.contrib.auth.context_processors.auth', 83 | 'django.contrib.messages.context_processors.messages', 84 | ], 85 | }, 86 | }, 87 | ] 88 | 89 | WSGI_APPLICATION = 'fireroad.wsgi.application' 90 | 91 | 92 | # Database 93 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 94 | 95 | DATABASES = { 96 | 'default': { 97 | 'ENGINE': 'django.db.backends.sqlite3', 98 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 99 | } 100 | } 101 | 102 | # Set up email if email_creds.txt file is present in this directory (format should be "host,email address,password") 103 | email_creds_path = os.path.join(os.path.dirname(__file__), 'email_creds.txt') 104 | if os.path.exists(email_creds_path): 105 | with open(email_creds_path, "r") as file: 106 | host, email, passwd = file.readline().strip().split(",") 107 | 108 | FR_EMAIL_ENABLED = True 109 | EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 110 | EMAIL_HOST = host # e.g. smtp.gmail.com 111 | EMAIL_USE_TLS = True 112 | EMAIL_PORT = 587 113 | EMAIL_HOST_USER = email 114 | EMAIL_HOST_PASSWORD = passwd 115 | else: 116 | FR_EMAIL_ENABLED = False 117 | 118 | # Password validation 119 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 120 | 121 | AUTH_PASSWORD_VALIDATORS = [ 122 | { 123 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 124 | }, 125 | { 126 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 127 | }, 128 | { 129 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 130 | }, 131 | { 132 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 133 | }, 134 | ] 135 | 136 | 137 | # Internationalization 138 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 139 | 140 | LANGUAGE_CODE = 'en-us' 141 | 142 | TIME_ZONE = 'UTC' 143 | 144 | USE_I18N = True 145 | 146 | USE_L10N = True 147 | 148 | USE_TZ = True 149 | 150 | 151 | # Static files (CSS, JavaScript, Images) 152 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 153 | 154 | STATIC_URL = '/static/' 155 | 156 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 157 | 158 | '''STATICFILES_DIRS = [ 159 | os.path.join(BASE_DIR, "catalog-files") 160 | ]''' 161 | 162 | LOGGING = { 163 | 'version': 1, 164 | 'handlers': { 165 | 'console':{ 166 | 'level':'DEBUG', 167 | 'class':'logging.StreamHandler', 168 | }, 169 | }, 170 | 'loggers': { 171 | 'django.request': { 172 | 'handlers':['console'], 173 | 'propagate': True, 174 | 'level':'DEBUG', 175 | } 176 | }, 177 | } 178 | -------------------------------------------------------------------------------- /requirements/diff.py: -------------------------------------------------------------------------------- 1 | import re 2 | import numpy as np 3 | from django.utils.html import escape 4 | 5 | def best_diff_sequence(old, new, allow_subs=True, max_delta=None): 6 | """Compute the best diff sequence between the old and new requirements list 7 | contents. Uses a simple DP-based edit distance algorithm. If allow subs is 8 | True, the diff sequence may use 0 to denote either substitutions or the same 9 | value.""" 10 | 11 | memo = np.zeros((len(old) + 1, len(new) + 1)) 12 | 13 | parent_pointers = np.zeros((len(old) + 1, len(new) + 1)) 14 | parent_pointers[-1,:] = 1 15 | parent_pointers[:,-1] = -1 16 | parent_pointers[-1,-1] = 0 17 | 18 | for i in reversed(range(len(old) + 1)): 19 | for j in reversed(range(len(new) + 1)): 20 | if i == len(old) and j == len(new): continue 21 | if max_delta is not None: 22 | if i - j >= max_delta: 23 | parent_pointers[i,j] = 1 24 | memo[i,j] = 1e4 25 | continue 26 | elif j - i >= max_delta: 27 | parent_pointers[i,j] = -1 28 | memo[i,j] = 1e4 29 | continue 30 | 31 | options = [] # Format: (score, label) 32 | if i < len(old) and j < len(new) and (allow_subs or old[i] == new[j]): 33 | options.append((memo[i+1, j+1] + (max(len(old[i]), len(new[j])) if old[i] != new[j] else 0), 0)) 34 | if i < len(old): 35 | options.append((memo[i+1, j] + len(old[i]) + 1, -1)) 36 | if j < len(new): 37 | options.append((memo[i, j+1] + len(new[j]) + 1, 1)) 38 | 39 | score, label = min(enumerate(options), key=lambda x: (x[1], x[0]))[1] 40 | memo[i,j] = score 41 | parent_pointers[i,j] = label 42 | 43 | best_sequence = [] 44 | i = 0 45 | j = 0 46 | while i <= len(old) and j <= len(new): 47 | best_sequence.append(parent_pointers[i,j]) 48 | if best_sequence[-1] == 0: 49 | i += 1 50 | j += 1 51 | elif best_sequence[-1] == 1: 52 | j += 1 53 | elif best_sequence[-1] == -1: 54 | i += 1 55 | return best_sequence[:-1] 56 | 57 | WORD_FINDER_REGEX = r"[\w'.-]+|[^\w'.-]" 58 | 59 | def delete_insert_diff_line(old, new): 60 | """Builds a basic diff line in which the old text is deleted and the new text 61 | is inserted.""" 62 | return "

    {}{}

    \n".format(old, new) 63 | 64 | def build_diff_line(old, new, max_delta=None): 65 | """Builds a single line of the diff.""" 66 | result = "

    " 67 | 68 | if old == new: 69 | result += old 70 | else: 71 | old_words = re.findall(WORD_FINDER_REGEX, old) 72 | new_words = re.findall(WORD_FINDER_REGEX, new) 73 | diff_sequence = best_diff_sequence(old_words, new_words, allow_subs=False, max_delta=max_delta) 74 | i = 0 75 | j = 0 76 | current_change = None 77 | for change in diff_sequence: 78 | if change == 0: # Same character (since allow subs is False) 79 | if current_change is not None: 80 | result += "" 81 | current_change = None 82 | result += escape(old_words[i]) 83 | i += 1 84 | j += 1 85 | elif change == 1: # Insertion 86 | if current_change != 1: 87 | if current_change is not None: 88 | result += "" 89 | result += "" 90 | current_change = 1 91 | result += escape(new_words[j]) 92 | j += 1 93 | elif change == -1: # Deletion 94 | if current_change != -1: 95 | if current_change is not None: 96 | result += "" 97 | result += "" 98 | current_change = -1 99 | result += escape(old_words[i]) 100 | i += 1 101 | if current_change is not None: 102 | result += "" 103 | result += "

    \n" 104 | return result 105 | 106 | def build_diff(old, new, changed_lines_only=False, max_line_delta=None, max_word_delta=None): 107 | """ 108 | Generates HTML to render a diff between the given two strings. 109 | """ 110 | old_lines = re.split(r'\r?\n', old) 111 | new_lines = re.split(r'\r?\n', new + '\n') 112 | diff_sequence = best_diff_sequence(old_lines, new_lines, max_delta=max_line_delta) 113 | print("Done diffing lines") 114 | result = "" 115 | i = 0 116 | j = 0 117 | for change in diff_sequence: 118 | if change == 0: # Same character (since allow subs is False) 119 | if not changed_lines_only or old_lines[i] != new_lines[j]: 120 | result += build_diff_line(old_lines[i], new_lines[j], max_delta=max_word_delta) 121 | i += 1 122 | j += 1 123 | elif change == 1: # Insertion 124 | result += "

    " + escape(new_lines[j]) + "

    \n" 125 | j += 1 126 | elif change == -1: # Deletion 127 | result += "

    " + escape(old_lines[i]) + "

    \n" 128 | i += 1 129 | return result 130 | -------------------------------------------------------------------------------- /catalog_parse/utils/parse_schedule.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import re 3 | from .catalog_constants import * 4 | 5 | # For when the schedule string contains multiple subjects, like 6 | # 12.S592: Lecture: xyz 7 | subject_id_regex = r'^([A-Z0-9.-]+)(\[J\])?$' 8 | 9 | quarter_info_regex = r"\(?(begins|ends|meets)\s+(.+?)(\.|\))" 10 | 11 | # Class type regex matches "Lecture:abc XX:" 12 | class_type_regex = r"([A-z0-9.-]+):(.+?)(?=\Z|\w+:)" 13 | 14 | # Time regex matches "MTWRF9-11 ( 1-123 )" or "MTWRF EVE (8-10) ( 1-234 )". 15 | time_regex = r"(? 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% block title %} 17 | FireRoad 18 | {% endblock %} 19 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | {% if request.user.is_staff %} 39 | 46 | {% endif %} 47 | 48 | 71 | 72 | 77 | 78 | {% if request.user.is_staff %} 79 | 86 | {% endif %} 87 | 88 | 103 | 104 | 105 | {% block pagebody %} {% endblock %} 106 | 107 | 108 | 109 | 110 | 111 | 112 | {% block pagescripts %}{% endblock %} 113 | 114 | 115 | -------------------------------------------------------------------------------- /common/decorators.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from django.http import HttpResponse 4 | from django.contrib.auth import authenticate, login 5 | from django.contrib.auth.models import User 6 | from django.shortcuts import render, redirect 7 | from django.conf import settings 8 | 9 | from .oauth_client import * 10 | from .models import Student, APIClient 11 | import json 12 | from .token_gen import * 13 | 14 | ALWAYS_LOGIN = False 15 | 16 | def user_has_student(user): 17 | try: 18 | s = user.student 19 | return s is not None 20 | except: 21 | return False 22 | 23 | def view_or_basicauth(view, request, test_func, realm = "", *args, **kwargs): 24 | """ 25 | This is a helper function used by both 'logged_in_or_basicauth' and 26 | 'has_perm_or_basicauth' that does the nitty of determining if they 27 | are already logged in or if they have provided proper http-authorization 28 | and returning the view if all goes well, otherwise responding with a 401. 29 | """ 30 | 31 | if request.user is None or not request.user.is_authenticated() or not user_has_student(request.user) or ALWAYS_LOGIN: 32 | key = 'HTTP_AUTHORIZATION' 33 | if key not in request.META: 34 | key = 'REDIRECT_HTTP_AUTHORIZATION' 35 | if key not in request.META: 36 | key = 'HTTP_X_AUTHORIZATION' 37 | if key in request.META: 38 | auth = request.META[key].split() 39 | if len(auth) == 2: 40 | if auth[0].lower() == "basic": 41 | # Basic authentication - this is not an API client 42 | uname, passwd = base64.b64decode(auth[1]).split(':') 43 | user = authenticate(username=uname, password=passwd) 44 | permissions = APIClient.universal_permission_flag() 45 | elif auth[0].lower() == "bearer": 46 | # The client bears a FireRoad-issued token 47 | user, permissions, error = extract_token_info(request, auth[1]) 48 | if error is not None: 49 | return HttpResponse(json.dumps(error), status=401, content_type="application/json") 50 | user.backend = 'django.contrib.auth.backends.ModelBackend' 51 | else: 52 | raise PermissionDenied 53 | 54 | request.session['permissions'] = permissions 55 | if user is not None: 56 | if user.is_active: 57 | login(request, user) 58 | request.user = user 59 | return view(request, *args, **kwargs) 60 | raise PermissionDenied 61 | #return redirect('login') 62 | else: 63 | if 'permissions' not in request.session: 64 | print("Setting universal permission flag - this should only occur in dev or from FireRoad-internal login.") 65 | request.session['permissions'] = APIClient.universal_permission_flag() 66 | return view(request, *args, **kwargs) 67 | 68 | ############################################################################# 69 | # 70 | def logged_in_or_basicauth(func, realm = ""): 71 | """ 72 | A simple decorator that requires a user to be logged in. If they are not 73 | logged in the request is examined for a 'authorization' header. 74 | 75 | If the header is present it is tested for basic authentication and 76 | the user is logged in with the provided credentials. 77 | 78 | If the header is not present a http 401 is sent back to the 79 | requestor to provide credentials. 80 | 81 | The purpose of this is that in several django projects I have needed 82 | several specific views that need to support basic authentication, yet the 83 | web site as a whole used django's provided authentication. 84 | 85 | The uses for this are for urls that are access programmatically such as 86 | by rss feed readers, yet the view requires a user to be logged in. Many rss 87 | readers support supplying the authentication credentials via http basic 88 | auth (and they do NOT support a redirect to a form where they post a 89 | username/password.) 90 | 91 | Use is simple: 92 | 93 | @logged_in_or_basicauth 94 | def your_view: 95 | ... 96 | 97 | You can provide the name of the realm to ask for authentication within. 98 | """ 99 | def wrapper(request, *args, **kwargs): 100 | # If it's a preflight request, don't even run the view 101 | if request.method == 'OPTIONS': 102 | return HttpResponse() 103 | return view_or_basicauth(func, request, 104 | lambda u: u.is_authenticated(), 105 | realm, *args, **kwargs) 106 | return wrapper 107 | 108 | ############################################################################# 109 | # 110 | def has_perm_or_basicauth(perm, realm = ""): 111 | """ 112 | This is similar to the above decorator 'logged_in_or_basicauth' 113 | except that it requires the logged in user to have a specific 114 | permission. 115 | 116 | Use: 117 | 118 | @logged_in_or_basicauth('asforums.view_forumcollection') 119 | def your_view: 120 | ... 121 | 122 | """ 123 | def view_decorator(func): 124 | def wrapper(request, *args, **kwargs): 125 | return view_or_basicauth(func, request, 126 | lambda u: u.has_perm(perm), 127 | realm, *args, **kwargs) 128 | return wrapper 129 | return view_decorator 130 | 131 | def require_token_permissions(*permission_names): 132 | """Decorator that makes sure that the request has a permissions flag that permits the 133 | given permission names.""" 134 | def view_decorator(view_func): 135 | def wrapper(request, *args, **kwargs): 136 | # Passthrough for dev server and local testing 137 | if not settings.RESTRICT_AUTH_REDIRECTS and settings.DEBUG: 138 | return view_func(request, *args, **kwargs) 139 | 140 | if not request.session["permissions"]: 141 | print("Session object has no permissions set") 142 | raise PermissionDenied 143 | permissions = APIClient.from_permissions_flag(request.session["permissions"]) 144 | for p_name in permission_names: 145 | try: 146 | if not getattr(permissions, p_name): 147 | raise PermissionDenied 148 | except AttributeError: 149 | print("Attribute not found: " + p_name) 150 | raise PermissionDenied 151 | return view_func(request, *args, **kwargs) 152 | return wrapper 153 | return view_decorator 154 | -------------------------------------------------------------------------------- /common/templates/common/docs/requirements.html: -------------------------------------------------------------------------------- 1 | {% extends "common/docs/base.html" %} 2 | 3 | {% block nav %} 4 | {% include "common/docs/sidebar.html" with active_id="requirements" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

    Requirements

    9 |

    The FireRoad API provides access to JSON representations of all available major/minor requirements. You can view these requirements and submit modification requests in the Requirements Editor.

    10 | 11 |

    Requirements Lists

    12 | 13 |
    /requirements/list_reqs (GET)
    14 |

    Returns a dictionary where the keys are list IDs of requirements lists, and the values are metadata dictionaries containing various titles for the corresponding lists.

    15 | 16 |
    /requirements/get_json/<list_id> (GET)
    17 |

    Use this endpoint to get a JSON representation of a course requirements list. The list_id should be one of the keys returned by /requirements/list_reqs, or else a bad request error is thrown. The return value of this endpoint is a JSON representation which may contain the following keys:

    18 |
      19 |
    • list-id - the requirements list ID
    • 20 |
    • short-title - a short title, e.g. "6-7"
    • 21 |
    • medium-title - a medium title, e.g. "WGS Minor"
    • 22 |
    • title-no-degree - a title without the degree name (e.g. "Computer Science and Engineering")
    • 23 |
    • title - the full title (e.g. "Bachelor of Science in Computer Science and Engineering")
    • 24 |
    • desc - an optional description of the statement or requirements list
    • 25 |
    • req - string requirement, such as "6.009" or "24 units in 8.200-8.299" (if not present, see reqs)
    • 26 |
    • plain-string - whether to interpret req as a parseable requirement ("6.009") or as a plain string ("24 units in 8.200-8.299"). Note that plain strings may have (distinct-)threshold keys attached, allowing the user to manually control progress.
    • 27 |
    • reqs - a list of nested requirements statements (if not present, see req)
    • 28 |
    • connection-type - logical connection type between the reqs (all or any, or none if it is a plain string)
    • 29 |
    • threshold - optional dictionary describing the threshold to satisfy this statement. Keys are: 30 |
        31 |
      • type - the type of inequality to apply (LT, GT, LTE, or GTE)
      • 32 |
      • cutoff - the numerical cutoff
      • 33 |
      • criterion - either subjects or units
      • 34 |
      35 |
    • 36 |
    • distinct-threshold - optional dictionary describing the number of distinct child requirements of this statement that must be satisfied. Keys are the same as threshold.
    • 37 |
    • threshold-desc - user-facing string describing the thresholds (if applicable)
    • 38 |
    39 | 40 |

    Requirements Progress

    41 | 42 |
    /requirements/progress (GET or POST)
    43 |

    Returns a JSON representation of a course requirements list, including user progresses. There are a few different ways to provide the user's courses and progress overrides to this endpoint:

    44 |
      45 |
    1. /requirements/progress/<list_id>/<courses> (GET) courses is a comma-separated list of subject IDs. (Progress overrides cannot be passed using this method. No authorization is necessary.)
    2. 46 |
    3. /requirements/progress/<list_id>?road=<road_id> (GET) road_id is the integer ID number of the user's road. The user must be logged in or an authorization token must be passed.
    4. 47 |
    5. /requirements/progress/<list_id> (POST) The request body should contain the JSON representation of the road to evaluate against. (No authorization is necessary.)
    6. 48 |
    49 |

    The JSON returned by this endpoint contains the following keys in addition to those defined above:

    50 |
      51 |
    • fulfilled - boolean indicating whether the requirement is completed
    • 52 |
    • progress - the integer progress toward the requirement, in units of criterion
    • 53 |
    • max - the maximum possible progress, serving as a denominator for progress
    • 54 |
    • percent_fulfilled - the percentage fulfilled
    • 55 |
    • sat_courses - a list of courses that satisfies this requirement
    • 56 |
    • is_bypassed - if present, a boolean indicating whether this requirement has been bypassed because of a progress assertion on one of its ancestors in the tree
    • 57 |
    • assertion - if present, a JSON object representing a progress assertion that was made on this requirement by the input road file. The format is the same as the progress assertion object defined in the road file spec.
    • 58 |
    59 | 60 |
    61 | 64 |
    65 | 68 |
    69 |

    70 |
    71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /common/templates/common/docs/sync.html: -------------------------------------------------------------------------------- 1 | {% extends "common/docs/base.html" %} 2 | 3 | {% block nav %} 4 | {% include "common/docs/sidebar.html" with active_id="sync" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |

    Sync

    9 |

    Upon obtaining an authentication token for a given user, FireRoad provides you access to that user's preferences, roads and schedules.

    10 | 11 |

    Preferences

    12 | 13 |
    /prefs/notes (GET), /prefs/set_notes (POST)
    14 |

    These endpoints handle read-write of notes, which the user can enter for any subject in the catalog. The format of the returned notes is a dictionary with the success key, and if that is true, a notes key containing a dictionary keyed by subject IDs.

    15 | 16 |
    /prefs/favorites (GET), /prefs/set_favorites (POST)
    17 |

    These endpoints handle read-write of favorite subjects. The format of the returned data is a dictionary with the success key, and if that is true, a favorites key containing a list of subject IDs.

    18 | 19 |
    /prefs/progress_overrides (GET), /prefs/set_progress_overrides (POST)
    20 |

    Deprecated. Use the progressOverrides key in the road file to store progress overrides.

    21 |

    These endpoints handle read-write of manual progress overrides, which the user can set for requirements lists to indicate progress toward completion. The format of the returned data is a dictionary with the success key, and if that is true, a progress_overrides key containing a dictionary keyed by requirements list key-paths (see the RequirementsListStatement implementation in the mobile app for more information).

    22 | 23 |
    /prefs/custom_courses (GET)
    24 |

    Retrieves the custom courses created by the user. Takes no input, and provides the list of custom courses in standard JSON course format under the custom_courses key of the returned dictionary.

    25 | 26 |
    /prefs/set_custom_course (POST)
    27 |

    Creates or updates a custom course. The input is a JSON dictionary containing the full description of a course to add or update (subject_id is required). By default, the course is set to "public": false (a value of true currently has no effect). Output has a success key indicating whether the update was successful.

    28 | 29 |
    /prefs/remove_custom_course (POST)
    30 |

    Removes a custom course from the user's list. The input is a JSON dictionary specifying the course to remove (subject_id is required). Output has a success key indicating whether the update was successful.

    31 | 32 |

    File Sync

    33 | 34 |
    /sync/roads (GET)
    35 |

    If a primary key is specified by the id query parameter, returns the contents of the given file as well as its last-modified agent. If no primary key is specified, returns a dictionary of primary-keys to metadata about each of the user's roads.

    36 | 37 |
    /sync/sync_road (POST)
    38 |

    This endpoint determines whether to change the remote copy of the file, update the local copy, or handle a sync conflict. The body of the request should be a JSON dictionary containing the following keys:

    39 | 40 |
      41 |
    • id: The primary key of the road to update (don't pass if adding a new file)
    • 42 |
    • contents: The contents of the road to update
    • 43 |
    • changed: The local last-modified date of the road
    • 44 |
    • downloaded: The date of the last download of the road from the server
    • 45 |
    • name: The road name (required if adding a new file, or if renaming an existing road)
    • 46 |
    • agent: The name of the device submitting the change
    • 47 |
    • override: Whether to override conflicts
    • 48 |
    Returns a JSON dictionary that may update the above keys and/or add the following keys:

    51 |
      52 |
    • success: Whether the file was successfully compared against its remote version
    • 53 |
    • error: A console error if success is false
    • 54 |
    • error_msg: A user-facing error to display if success is false
    • 55 |
    • result: A string indicating the result of the operation, e.g. "update_remote", "update_local", "conflict", or "no_change"
    • 56 |
    • other_name, other_agent, other_date, other_contents, this_agent, this_date: Keys that are specified in the case of a conflict. In this case, the user should select whether to keep the local copy, the remote copy, or both. If keeping the local copy, the client should submit a new sync_road request with the override flag set to true.
    • 57 |
    58 | 59 |
    /sync/delete_road (POST)
    60 |

    Deletes the file specified by the id key in the body of the request.

    61 | 62 |
    /sync/schedules, /sync/sync_schedule, /sync/delete_schedule
    63 |

    Analogous to the above endpoints, but for schedules.

    64 | 65 | 66 |
    67 | 70 |
    71 | 74 |
    75 |

    76 |
    77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /requirements/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.http import HttpResponse, HttpResponseBadRequest 3 | from django.views.decorators.csrf import csrf_exempt 4 | from .models import * 5 | from django.contrib.auth import login, authenticate, logout 6 | from django.core.exceptions import PermissionDenied, ObjectDoesNotExist 7 | from common.decorators import logged_in_or_basicauth 8 | import json 9 | import os 10 | import requests 11 | from courseupdater.views import * 12 | from sync.models import Road 13 | import re 14 | from progress import RequirementsProgress 15 | from catalog.models import Course, Attribute, HASSAttribute, GIRAttribute, CommunicationAttribute 16 | import logging 17 | 18 | REQUIREMENTS_EXT = ".reql" 19 | 20 | SUBJECT_ID_KEY = "subject_id" 21 | SUBJECT_ID_ALT_KEY = "id" 22 | 23 | def get_json(request, list_id): 24 | """Returns the raw JSON for a given requirements list, without user 25 | course progress.""" 26 | 27 | try: 28 | req = RequirementsList.objects.get(list_id=list_id + REQUIREMENTS_EXT) 29 | # to pretty-print, use these keyword arguments to json.dumps: 30 | # sort_keys=True, indent=4, separators=(',', ': ') 31 | return HttpResponse(json.dumps(req.to_json_object(full=True)), content_type="application/json") 32 | except ObjectDoesNotExist: 33 | return HttpResponseBadRequest("the requirements list {} does not exist".format(list_id)) 34 | 35 | def compute_progress(request, list_id, course_list, progress_overrides, progress_assertions): 36 | """Utility function for road_progress and progress that computes and returns 37 | the progress on the given requirements list.""" 38 | try: 39 | req = RequirementsList.objects.get(list_id=list_id + REQUIREMENTS_EXT) 40 | except ObjectDoesNotExist: 41 | return HttpResponseBadRequest("the requirements list {} does not exist".format(list_id)) 42 | 43 | # Get Course objects 44 | course_objs = [] 45 | #required to give generic courses unique id's so muliple can count towards requirement 46 | unique_generic_id = 0 47 | for subject_id in course_list: 48 | try: 49 | course_objs.append(Course.public_courses().get(subject_id=subject_id)) 50 | except ObjectDoesNotExist: 51 | try: 52 | course_objs.append(Course.make_generic(subject_id,unique_generic_id)) 53 | unique_generic_id += 1 54 | except ValueError: 55 | print("Warning: course {} does not exist in the catalog".format(subject_id)) 56 | 57 | 58 | # Create a progress object for the requirements list 59 | prog = RequirementsProgress(req, list_id) 60 | prog.compute(course_objs, progress_overrides, progress_assertions) 61 | # to pretty-print, use these keyword arguments to json.dumps: 62 | # sort_keys=True, indent=4, separators=(',', ': ') 63 | return HttpResponse(json.dumps(prog.to_json_object(True)), content_type="application/json") 64 | 65 | #@logged_in_or_basicauth 66 | def road_progress_get(request, list_id): 67 | """Returns the raw JSON for a given requirements list including user 68 | progress. A 'road' query parameter should be passed that indicates the ID 69 | number of the road that is being checked.""" 70 | road_id = request.GET.get("road", "") 71 | if road_id is None or len(road_id) == 0: 72 | return HttpResponseBadRequest("need a road ID") 73 | try: 74 | road_id = int(road_id) 75 | except: 76 | return HttpResponseBadRequest("road ID must be an integer") 77 | 78 | try: 79 | road = Road.objects.get(user=request.user, pk=road_id) 80 | except ObjectDoesNotExist: 81 | available_roads = Road.objects.filter(user=request.user) 82 | return HttpResponseBadRequest("the road does not exist on the server. Available roads: " + 83 | ", ".join(str(road.pk) for road in available_roads)) 84 | 85 | try: 86 | contents = json.loads(Road.expand(road.contents)) 87 | except: 88 | return HttpResponseBadRequest("badly formatted road contents") 89 | 90 | progress_overrides = contents.get("progressOverrides", {}) 91 | progress_assertions = contents.get("progressAssertions", {}) 92 | 93 | return compute_progress(request, list_id, read_subjects(contents), progress_overrides, progress_assertions) 94 | 95 | def road_progress_post(request, list_id): 96 | """Returns the raw JSON for a given requirements list including user 97 | progress. The POST body should contain the JSON for the road.""" 98 | try: 99 | contents = json.loads(request.body) 100 | except: 101 | return HttpResponseBadRequest("badly formatted road contents") 102 | 103 | progress_overrides = contents.get("progressOverrides", {}) 104 | progress_assertions = contents.get("progressAssertions", {}) 105 | result = compute_progress(request, list_id, read_subjects(contents), progress_overrides, progress_assertions) 106 | return result 107 | 108 | def read_subjects(contents): 109 | """Extracts a list of subjects from a given road JSON object.""" 110 | course_list = [] 111 | for subj in contents.get("selectedSubjects", []): 112 | if SUBJECT_ID_KEY in subj: 113 | course_list.append(subj[SUBJECT_ID_KEY]) 114 | elif SUBJECT_ID_ALT_KEY in subj: 115 | course_list.append(subj[SUBJECT_ID_ALT_KEY]) 116 | return course_list 117 | 118 | @csrf_exempt 119 | def road_progress(request, list_id): 120 | """Returns the raw JSON for a given requirements list including user 121 | progress. If the method is POST, expects the road contents in the request 122 | body. If it is GET, expects an authorization token and a 'road' query 123 | parameter containing the ID number of the road to check.""" 124 | 125 | if request.method == 'POST': 126 | return road_progress_post(request, list_id) 127 | elif 'road' in request.GET: 128 | return road_progress_get(request, list_id) 129 | else: 130 | # Empty course list 131 | return progress(request, list_id, "") 132 | 133 | def progress(request, list_id, courses): 134 | """Returns the raw JSON for a given requirements list including user 135 | progress. The courses used to evaluate the requirements list are provided 136 | in courses as a comma-separated list of subject IDs.""" 137 | return compute_progress(request, list_id, [c for c in courses.split(",") if len(c)], {}, {}) 138 | 139 | def list_reqs(request): 140 | """Return a JSON dictionary of all available requirements lists, with the 141 | basic metadata for those lists.""" 142 | list_ids = { } 143 | for req in RequirementsList.objects.all(): 144 | req_metadata = req.to_json_object(full=False) 145 | del req_metadata[JSONConstants.list_id] 146 | list_ids[req.list_id.replace(REQUIREMENTS_EXT, "")] = req_metadata 147 | return HttpResponse(json.dumps(list_ids), content_type="application/json") 148 | -------------------------------------------------------------------------------- /update_catalog.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import shutil 5 | import django 6 | django.setup() 7 | 8 | from courseupdater.views import get_current_update 9 | if __name__ == '__main__': 10 | if len(sys.argv) > 1: 11 | from courseupdater.models import CatalogUpdate 12 | update = CatalogUpdate(semester=sys.argv[1]) 13 | else: 14 | update = get_current_update() 15 | if update is None or update.is_started or update.is_completed: 16 | exit(0) 17 | update.is_started = True 18 | update.save() 19 | 20 | from django.conf import settings 21 | from django.core.mail import send_mail 22 | import traceback 23 | from django.core.exceptions import ObjectDoesNotExist 24 | import catalog_parse as cp 25 | from requirements.diff import * 26 | from courseupdater.models import CatalogCorrection 27 | from catalog.models import FIELD_TO_CSV 28 | 29 | def email_results(message, recipients): 30 | """ 31 | Sends an email to the given recipients with the given message. Prints to 32 | the console if email is not set up. 33 | """ 34 | if not settings.FR_EMAIL_ENABLED: 35 | print("Email not configured. Message:") 36 | print(message) 37 | return 38 | 39 | email_from = "FireRoad <{}>".format(settings.EMAIL_HOST_USER) 40 | send_mail("Catalog update", message, email_from, recipients) 41 | 42 | def update_progress(progress, message): 43 | update = get_current_update() 44 | if update is not None: 45 | update.progress = progress 46 | update.progress_message = message 47 | update.save() 48 | else: 49 | # The update was canceled 50 | raise KeyboardInterrupt 51 | 52 | def get_corrections(): 53 | """Gets the corrections from the CatalogCorrection table and formats them 54 | appropriately.""" 55 | raw_corrections = CatalogCorrection.objects.all().values() 56 | corrections = [] 57 | def format(value): 58 | if isinstance(value, bool): 59 | return "Y" if value else None 60 | return value if value else None 61 | 62 | for corr in raw_corrections: 63 | new_corr = {} 64 | for k, v in corr.items(): 65 | if k in FIELD_TO_CSV and k != "offered_this_year" and format(v): 66 | new_corr[FIELD_TO_CSV[k]] = format(v) 67 | corrections.append(new_corr) 68 | return corrections 69 | 70 | def write_diff(old_path, new_path, diff_path): 71 | if not os.path.exists(old_path): 72 | with open(diff_path, 'w') as file: 73 | file.write("

    This is a new catalog version, so no diff is available.

    ") 74 | return 75 | elif not os.path.exists(new_path): 76 | with open(diff_path, 'w') as file: 77 | file.write("

    The new version of the catalog has no files - something might have gone wrong?

    ") 78 | return 79 | 80 | diff_file = open(diff_path, 'w') 81 | old_file = open(old_path, 'r') 82 | new_file = open(new_path, 'r') 83 | 84 | old_contents = old_file.read().decode('utf-8') 85 | new_contents = new_file.read().decode('utf-8') 86 | 87 | old_lines = old_contents.split('\n') 88 | new_lines = new_contents.split('\n') 89 | 90 | # Subject ID comes first 91 | old_headings = old_lines[0].split(",")[1:] 92 | new_headings = new_lines[0].split(",")[1:] 93 | 94 | old_courses = {line[:line.find(",")]: line for line in old_lines[1:]} 95 | new_courses = {line[:line.find(",")]: line for line in new_lines[1:]} 96 | ids = sorted(set(old_courses.keys()) | set(new_courses.keys())) 97 | wrote_to_file = False 98 | for i, id in enumerate(ids): 99 | if i % 100 == 0: 100 | print(i, "of", len(ids)) 101 | old_course = old_courses.get(id, "") 102 | new_course = new_courses.get(id, "") 103 | 104 | if old_course != new_course: 105 | if abs(len(new_course) - len(old_course)) >= 40: 106 | diff = delete_insert_diff_line(old_course.encode('utf-8'), new_course.encode('utf-8')) 107 | else: 108 | diff = build_diff_line(old_course, new_course, max_delta=40).encode('utf-8') 109 | diff_file.write(diff) 110 | wrote_to_file = True 111 | 112 | if not wrote_to_file: 113 | diff_file.write("No files changed due to this update.") 114 | 115 | old_file.close() 116 | new_file.close() 117 | diff_file.close() 118 | 119 | if __name__ == '__main__': 120 | # update is loaded at the top of the script for efficiency 121 | try: 122 | semester = 'sem-' + update.semester 123 | out_path = os.path.join(settings.CATALOG_BASE_DIR, "raw", semester) 124 | evaluations_path = os.path.join(settings.CATALOG_BASE_DIR, "evaluations.js") 125 | if not os.path.exists(evaluations_path): 126 | print("No evaluations file found - consider adding one (see catalog_parse/utils/parse_evaluations.py).") 127 | evaluations_path = None 128 | equivalences_path = os.path.join(settings.CATALOG_BASE_DIR, "equivalences.json") 129 | if not os.path.exists(equivalences_path): 130 | print("No equivalences file found - consider adding one (see catalog_parse/utils/parse_equivalences.py).") 131 | equivalences_path = None 132 | 133 | cp.parse(out_path, equivalences_path=equivalences_path, 134 | progress_callback=update_progress, 135 | write_virtual_status=update.designate_virtual_status) 136 | 137 | consensus_path = os.path.join(settings.CATALOG_BASE_DIR, semester + "-new") 138 | if os.path.exists(consensus_path): 139 | shutil.rmtree(consensus_path) 140 | 141 | # Get corrections and convert from field names to CSV headings 142 | cp.build_consensus(os.path.join(settings.CATALOG_BASE_DIR, "raw"), 143 | consensus_path, 144 | corrections=get_corrections(), 145 | evaluations_path=evaluations_path) 146 | 147 | # Write a diff so it's easier to visualize changes 148 | update_progress(95.0, "Finding differences...") 149 | print("Finding differences...") 150 | 151 | old_path = os.path.join(settings.CATALOG_BASE_DIR, semester, "courses.txt") 152 | new_path = os.path.join(settings.CATALOG_BASE_DIR, semester + "-new", "courses.txt") 153 | write_diff(old_path, new_path, os.path.join(settings.CATALOG_BASE_DIR, "diff.txt")) 154 | 155 | # Make delta for informative purposes 156 | delta = cp.make_delta(consensus_path, os.path.join(settings.CATALOG_BASE_DIR, semester)) 157 | if len(delta) > 0: 158 | message = "The following {} files changed: ".format(len(delta)) + ", ".join(sorted(delta)) 159 | else: 160 | message = "No files changed due to the catalog update - no action required." 161 | if len(sys.argv) > 2: 162 | email_results(message, sys.argv[2:]) 163 | else: 164 | print(message) 165 | 166 | except: 167 | print("An error occurred while executing the update:") 168 | traceback.print_exc() 169 | 170 | update = get_current_update() 171 | if update is not None: 172 | update.progress = 100.0 173 | update.progress_message = "Done processing." 174 | update.save() 175 | -------------------------------------------------------------------------------- /analytics/static/analytics/js/analytics.js: -------------------------------------------------------------------------------- 1 | 2 | var graphBackgroundColors = [ 3 | 'rgba(142, 8, 48, 0.4)', 4 | 'rgba(143, 9, 105, 0.4)', 5 | 'rgba(227, 59, 59, 0.4)', 6 | 'rgba(201, 27, 121, 0.4)', 7 | 'rgba(77, 8, 177, 0.4)', 8 | ]; 9 | 10 | var graphBorderColors = [ 11 | 'rgba(142, 8, 48, 1)', 12 | 'rgba(143, 9, 105, 1.0)', 13 | 'rgba(227, 59, 59, 1.0)', 14 | 'rgba(201, 27, 121, 1.0)', 15 | 'rgba(77, 8, 177, 1.0)', 16 | ]; 17 | 18 | var timeframe = "day"; 19 | 20 | var totalRequestsChart = null; 21 | var userAgentsChart = null; 22 | var loggedInUsersChart = null; 23 | var semestersChart = null; 24 | var requestPathsChart = null; 25 | 26 | function makeBarChartOptions(show_all) { 27 | return { 28 | scales: { 29 | yAxes: [{ 30 | ticks: { 31 | beginAtZero: true 32 | }, 33 | stacked: true, 34 | }], 35 | xAxes: [{ 36 | ticks: show_all ? {} : { 37 | autoSkip: true, 38 | maxTicksLimit: 10 39 | }, 40 | barPercentage: 1.0, 41 | stacked: true, 42 | }] 43 | }, 44 | aspectRatio: 1.6 45 | } 46 | } 47 | 48 | // Reloads the data from the server with a specific time frame. 49 | function reloadData() { 50 | $("#total-requests-scorecard").html(" "); 51 | $("#requests-scorecard-ind").addClass("active"); 52 | $("#total-requests-ind").addClass("active"); 53 | $.ajax({ 54 | url: "/analytics/total_requests/" + timeframe, 55 | data: null, 56 | success: function (result) { 57 | var ctx = document.getElementById('total-requests-chart').getContext('2d'); 58 | var data = { 59 | labels: result.labels, 60 | datasets: [{ 61 | label: '# of Requests', 62 | data: result.data, 63 | backgroundColor: graphBackgroundColors[0], 64 | borderColor: graphBorderColors[0], 65 | borderWidth: 1 66 | }] 67 | } 68 | if (!totalRequestsChart) { 69 | totalRequestsChart = new Chart(ctx, { 70 | type: 'bar', 71 | data: data, 72 | options: makeBarChartOptions(false) 73 | }); 74 | } else { 75 | totalRequestsChart.data = data; 76 | totalRequestsChart.update(); 77 | } 78 | $("#total-requests-scorecard").text(result.total); 79 | $("#requests-scorecard-ind").removeClass("active"); 80 | $("#total-requests-ind").removeClass("active"); 81 | } 82 | }); 83 | 84 | $("#active-users-scorecard").html(" "); 85 | $("#users-scorecard-ind").addClass("active"); 86 | $("#unique-users-ind").addClass("active"); 87 | $.ajax({ 88 | url: "/analytics/logged_in_users/" + timeframe, 89 | data: null, 90 | success: function (result) { 91 | var ctx = document.getElementById('unique-users-chart').getContext('2d'); 92 | var data = { 93 | labels: result.labels, 94 | datasets: [{ 95 | label: 'Unique Users', 96 | data: result.data, 97 | backgroundColor: graphBackgroundColors[0], 98 | borderColor: graphBorderColors[0], 99 | borderWidth: 1 100 | }] 101 | } 102 | if (!loggedInUsersChart) { 103 | loggedInUsersChart = new Chart(ctx, { 104 | type: 'bar', 105 | data: data, 106 | options: makeBarChartOptions(false) 107 | }); 108 | } else { 109 | loggedInUsersChart.data = data; 110 | loggedInUsersChart.update(); 111 | } 112 | $("#active-users-scorecard").text(result.total); 113 | $("#users-scorecard-ind").removeClass("active"); 114 | $("#unique-users-ind").removeClass("active"); 115 | } 116 | }); 117 | 118 | $("#user-agents-ind").addClass("active"); 119 | $.ajax({ 120 | url: "/analytics/user_agents/" + timeframe, 121 | data: null, 122 | success: function (result) { 123 | var ctx = document.getElementById('user-agents-chart').getContext('2d'); 124 | var datasets = []; 125 | var i = 0; 126 | for (var key in result.data) { 127 | if (!result.data.hasOwnProperty(key)) { 128 | continue; 129 | } 130 | datasets.push({ 131 | label: key, 132 | data: result.data[key], 133 | backgroundColor: graphBackgroundColors[i % graphBackgroundColors.length], 134 | borderColor: graphBorderColors[i % graphBorderColors.length], 135 | borderWidth: 1 136 | }) 137 | i++; 138 | } 139 | var data = { 140 | labels: result.labels, 141 | datasets: datasets 142 | } 143 | if (!userAgentsChart) { 144 | userAgentsChart = new Chart(ctx, { 145 | type: 'bar', 146 | data: data, 147 | options: makeBarChartOptions(false) 148 | }); 149 | } else { 150 | userAgentsChart.data = data; 151 | userAgentsChart.update(); 152 | } 153 | $("#user-agents-ind").removeClass("active"); 154 | } 155 | }); 156 | 157 | $("#user-semesters-ind").addClass("active"); 158 | $.ajax({ 159 | url: "/analytics/user_semesters/" + timeframe, 160 | data: null, 161 | success: function (result) { 162 | var ctx = document.getElementById('user-semesters-chart').getContext('2d'); 163 | var data = { 164 | labels: result.labels, 165 | datasets: [{ 166 | label: "# of Users", 167 | data: result.data, 168 | backgroundColor: graphBackgroundColors[0], 169 | borderColor: graphBorderColors[0], 170 | borderWidth: 1 171 | }] 172 | } 173 | if (!semestersChart) { 174 | semestersChart = new Chart(ctx, { 175 | type: 'bar', 176 | data: data, 177 | options: makeBarChartOptions(true) 178 | }); 179 | } else { 180 | semestersChart.data = data; 181 | semestersChart.update(); 182 | } 183 | $("#user-semesters-ind").removeClass("active"); 184 | } 185 | }); 186 | 187 | $("#request-paths-ind").addClass("active"); 188 | $.ajax({ 189 | url: "/analytics/request_paths/" + timeframe, 190 | data: null, 191 | success: function (result) { 192 | var ctx = document.getElementById('request-paths-chart').getContext('2d'); 193 | var data = { 194 | labels: result.labels, 195 | datasets: [{ 196 | label: "# of Requests", 197 | data: result.data, 198 | backgroundColor: graphBackgroundColors[0], 199 | borderColor: graphBorderColors[0], 200 | borderWidth: 1 201 | }] 202 | } 203 | if (!requestPathsChart) { 204 | requestPathsChart = new Chart(ctx, { 205 | type: 'bar', 206 | data: data, 207 | options: makeBarChartOptions(true) 208 | }); 209 | } else { 210 | requestPathsChart.data = data; 211 | requestPathsChart.update(); 212 | } 213 | $("#request-paths-ind").removeClass("active"); 214 | } 215 | }); 216 | 217 | $("#active-roads-scorecard").html(" "); 218 | $("#active-schedules-scorecard").html(" "); 219 | $("#active-roads-ind").addClass("active"); 220 | $("#active-schedules-ind").addClass("active"); 221 | $.ajax({ 222 | url: "/analytics/active_documents/" + timeframe, 223 | data: null, 224 | success: function (result) { 225 | $("#active-roads-scorecard").text(result.roads); 226 | $("#active-schedules-scorecard").text(result.schedules); 227 | $("#active-roads-ind").removeClass("active"); 228 | $("#active-schedules-ind").removeClass("active"); 229 | } 230 | }); 231 | } 232 | 233 | // Initialize the time frame selector 234 | $(document).ready(function(){ 235 | $('select').formSelect(); 236 | }); 237 | reloadData(); 238 | 239 | function timeframeChanged() { 240 | timeframe = $('#timeframe-select').val(); 241 | reloadData(); 242 | } 243 | -------------------------------------------------------------------------------- /catalog_parse/consensus_catalog.py: -------------------------------------------------------------------------------- 1 | """ 2 | Takes a directory with various semesters of "raw" catalog information, and 3 | synthesizes them into a "consensus" catalog containing the most recent version 4 | of each course. The related file is copied directly from the most recent semester. 5 | """ 6 | 7 | import os 8 | import sys 9 | import csv 10 | import re 11 | import pandas as pd 12 | import numpy as np 13 | from utils.catalog_constants import * 14 | from utils.parse_evaluations import * 15 | 16 | KEYS_TO_WRITE = [key for key in CONDENSED_ATTRIBUTES if key != CourseAttribute.subjectID] + [CourseAttribute.sourceSemester, CourseAttribute.isHistorical] 17 | 18 | def semester_sort_key(x): 19 | comps = x.split('-') 20 | return int(comps[2]) * 10 + (5 if comps[1] == "fall" else 0) 21 | 22 | def make_corrections(corrections, consensus): 23 | """Based on the given correction dictionary objects, modifies 24 | the appropriate fields in the given consensus dataframe.""" 25 | for correction in corrections: 26 | subject_id = correction["Subject Id"] 27 | if '*' in subject_id: 28 | # Use regex matching to find appropriate rows 29 | regex = re.escape(subject_id).replace('\*', '.') 30 | consensus_rows = consensus[consensus.index.str.match(regex)] 31 | for idx, consensus_row in consensus_rows.iterrows(): 32 | for col in correction: 33 | if col == "Subject Id": continue 34 | if correction[col]: 35 | if col not in consensus.columns: 36 | consensus[col] = "" 37 | print("Correction for {}: {} ==> {}".format(idx, col, correction[col])) 38 | consensus.ix[idx][col] = correction[col] 39 | 40 | elif subject_id in consensus.index: 41 | # Find the subject in the consensus dataframe 42 | consensus_row = consensus.ix[subject_id] 43 | for col in correction: 44 | if col == "Subject Id": continue 45 | if correction[col]: 46 | print("Correction for {}: {} ==> {}".format(subject_id, col, correction[col])) 47 | consensus_row[col] = correction[col] 48 | 49 | else: 50 | # Add the subject 51 | print("Correction: adding subject {}".format(subject_id)) 52 | consensus.loc[subject_id] = {col: correction.get(col, None) for col in consensus.columns} 53 | 54 | 55 | def build_consensus(base_path, out_path, corrections=None, 56 | evaluations_path=None): 57 | if not os.path.exists(out_path): 58 | os.mkdir(out_path) 59 | 60 | if evaluations_path is not None: 61 | eval_data = load_evaluation_data(evaluations_path) 62 | else: 63 | eval_data = None 64 | 65 | semester_data = {} 66 | 67 | for semester in os.listdir(base_path): 68 | if 'sem-' not in semester: continue 69 | all_courses = pd.read_csv(os.path.join(base_path, semester, 'courses.txt'), dtype=str).replace(np.nan, '', regex=True) 70 | semester_data[semester] = all_courses 71 | 72 | # Sort in reverse chronological order 73 | semester_data = sorted(semester_data.items(), key=lambda x: semester_sort_key(x[0]), reverse=True) 74 | if len(semester_data) == 0: 75 | print("No raw semester data found.") 76 | return 77 | 78 | # Build consensus by iterating from new to old 79 | consensus = None 80 | last_size = 0 81 | for i, (semester, data) in enumerate(semester_data): 82 | data[CourseAttribute.sourceSemester] = semester[semester.find("-") + 1:] 83 | data[CourseAttribute.isHistorical] = "Y" if (i != 0) else "" 84 | 85 | # Get set of old subject IDs that have been renumbered in future 86 | # semesters 87 | old_ids = set().union(*( 88 | data.loc[:, CourseAttribute.oldID].replace("", np.nan).dropna() 89 | if CourseAttribute.oldID in data.columns else [] 90 | for semester, data in semester_data[:i] 91 | )) 92 | # Remove the old IDs 93 | data = data.loc[~data[CourseAttribute.subjectID].isin(old_ids)] 94 | 95 | if consensus is None: 96 | consensus = data 97 | else: 98 | if CourseAttribute.oldID in data.columns: 99 | # Propagate old ID field to newer semesters 100 | for _, subject_id, old_id in ( 101 | data.replace("", np.nan) 102 | .dropna(subset=[CourseAttribute.oldID]) 103 | .loc[:, (CourseAttribute.subjectID, CourseAttribute.oldID)] 104 | .itertuples() 105 | ): 106 | consensus[CourseAttribute.oldID][ 107 | consensus[CourseAttribute.subjectID] == subject_id 108 | ] = old_id 109 | consensus = pd.concat([consensus, data], sort=False) 110 | 111 | consensus = consensus.drop_duplicates(subset=[CourseAttribute.subjectID], keep='first') 112 | print("Added {} courses with {}.".format(len(consensus) - last_size, semester)) 113 | last_size = len(consensus) 114 | 115 | consensus.set_index(CourseAttribute.subjectID, inplace=True) 116 | if corrections is not None: 117 | make_corrections(corrections, consensus) 118 | 119 | if eval_data is not None: 120 | parse_evaluations(eval_data, consensus) 121 | 122 | print("Writing courses...") 123 | seen_departments = set() 124 | for subject_id in consensus.index: 125 | if "." not in subject_id: continue 126 | dept = subject_id[:subject_id.find(".")] 127 | if dept in seen_departments: continue 128 | 129 | dept_courses = consensus[consensus.index.str.startswith(dept + ".")] 130 | write_df(dept_courses, os.path.join(out_path, dept + ".txt")) 131 | seen_departments.add(dept) 132 | 133 | write_df(consensus, os.path.join(out_path, "courses.txt")) 134 | write_condensed_files(consensus, out_path) 135 | 136 | # Copy the first available related file 137 | for semester, data in semester_data: 138 | related_path = os.path.join(base_path, semester, "related.txt") 139 | if os.path.exists(related_path): 140 | with open(related_path, 'r') as file: 141 | with open(os.path.join(out_path, "related.txt"), 'w') as outfile: 142 | for line in file: 143 | outfile.write(line) 144 | break 145 | 146 | def write_condensed_files(consensus, out_path, split_count=4): 147 | for i in range(split_count): 148 | lower_bound = int(i / 4.0 * len(consensus)) 149 | upper_bound = min(len(consensus), int((i + 1) / 4.0 * len(consensus))) 150 | write_df(consensus[KEYS_TO_WRITE].iloc[lower_bound:upper_bound], os.path.join(out_path, "condensed_{}.txt".format(i))) 151 | 152 | def write_df(df, path): 153 | """Writes the df to the given path with appropriate quoting.""" 154 | with open(path, 'w') as file: 155 | file.write(','.join([CourseAttribute.subjectID] + list(df.columns)) + '\n') 156 | file.write(df.to_csv(header=False, quoting=csv.QUOTE_NONNUMERIC).replace('""', '')) 157 | 158 | if __name__ == "__main__": 159 | if len(sys.argv) < 2: 160 | print("Usage: python consensus_catalog.py raw-dir out-dir [evaluations-file]") 161 | exit(1) 162 | 163 | in_path = sys.argv[1] 164 | out_path = sys.argv[2] 165 | 166 | if len(sys.argv) > 2: 167 | eval_path = sys.argv[3] 168 | else: 169 | eval_path = None 170 | 171 | if os.path.exists(out_path): 172 | print("Fatal: the directory {} already exists. Please delete it or choose a different location.".format(out_path)) 173 | exit(1) 174 | 175 | build_consensus(in_path, out_path, evaluations_path=eval_path) 176 | --------------------------------------------------------------------------------