Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e96c101
Added searching/highlighting support for instructors
Unix-Code May 2, 2019
4217581
Added searching/highlighting support for instructors
Unix-Code May 3, 2019
9d38b91
Refactored searching/highlighting to be more DRY with a base class
Unix-Code May 3, 2019
666fc04
fixing merge
danguddemi May 9, 2019
da289f3
Merge pull request #48 from chilipot/feature/add-searching-for-instru…
danguddemi May 9, 2019
be90083
Feature/serverless transition (#50)
Unix-Code May 9, 2019
9be8941
Feature/fix pipeline code and imports (#51)
Unix-Code May 9, 2019
db47fdb
Feature/serverless transition pt2 (#52)
Unix-Code May 9, 2019
0e941a8
Patch/app environment in serverless config (#54)
Unix-Code May 9, 2019
f2e1888
new index setup + config (#55)
Unix-Code May 10, 2019
5774638
pin version (#56)
Unix-Code May 10, 2019
b08176a
faceting/filtering by multiple term, department, and instructor ids (…
Unix-Code May 11, 2019
9fb4f18
added question categories
Unix-Code May 18, 2019
fe96f21
added survey controller functionality
Unix-Code May 18, 2019
d3d6c0b
Merge branches 'feature/add-question-categories' and 'integration' of…
Unix-Code May 18, 2019
e40d83b
added categories endpoint
Unix-Code May 18, 2019
f811ee9
fixed formatting
Unix-Code May 18, 2019
5502e34
Merge pull request #58 from chilipot/feature/add-question-categories
danguddemi May 18, 2019
a7cf2b5
Add Gitter Badge
danguddemi May 21, 2019
28b212b
Add Gitter Badge (#61)
danguddemi May 21, 2019
22d3ada
Feature/categories for term (#72)
Unix-Code Sep 4, 2019
52d5959
changelog
Unix-Code Sep 4, 2019
b17e10d
changelog
Unix-Code Sep 4, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.3.0
* (feature) search questions/answers for category
* (feature) submitting review for a course report
* (patch) minor improvements to API + fortification of Flask architecture

## 0.2.0
* (feature) searching by instructor and department
* (patch) add app environment to serverless config
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[![CircleCI](https://circleci.com/gh/chilipot/TRACE-API/tree/master.svg?style=svg)](https://circleci.com/gh/chilipot/TRACE-API/tree/master)
[![Join the chat at https://gitter.im/chilipot/TRACE-API](https://badges.gitter.im/chilipot/TRACE-API.svg)](https://gitter.im/chilipot/TRACE-API?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
12 changes: 11 additions & 1 deletion api/controller/report_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from api.controller import api
from api.service.report_service import get_all_courses, get_single_course, get_single_report, \
search_courses, search_highlights_courses
search_courses, search_highlights_courses, save_user_report_response
from api.utils.constants import DEFAULT_PAGE_SIZE
from api.utils.helpers import responsify, get_id_facets_from_request

Expand Down Expand Up @@ -59,3 +59,13 @@ def get_scores(report_id):
else:
# Avoiding jsonify because it can be slow
return responsify(report), 200


@api.route('course/<int:report_id>/response/<int:user_id>', methods=['POST'])
def save_user_response(report_id, user_id):
"""
Saves a report response for a specific user
"""
response_data = request.get_json() or {}
save_user_report_response(report_id, user_id, response_data)
return responsify({'status': 'success'}), 200
13 changes: 12 additions & 1 deletion api/controller/term_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from flask import request

from api.controller import api
from api.service.term_service import get_all_terms, get_single_term
from api.service.term_service import get_all_terms, get_single_term, get_single_term_categories
from api.utils.constants import DEFAULT_PAGE_SIZE
from api.utils.helpers import responsify

Expand Down Expand Up @@ -31,3 +31,14 @@ def get_term(term_id):
flask.abort(404)
else:
return responsify(term), 200


@api.route('term/<int:term_id>/categories')
def get_term_categories(term_id):
"""
get a term given its identifier and return the
categories of questions associated with it
"""
results = get_single_term_categories(term_id)

return responsify(results), 200
3 changes: 3 additions & 0 deletions api/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
from api.model.question import Question
from api.model.course import Course
from api.model.score_data import ScoreData
from api.model.term_categories import TermCategories
from api.model.category_answers import CategoryAnswers
from api.model.response import Response
10 changes: 10 additions & 0 deletions api/model/category_answers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer, ForeignKey

from api.model.mixins import Base, Dictable


class CategoryAnswers(Base, Dictable):
__tablename__ = 'category_answers'

lookup_answer_id = Column(Integer, ForeignKey('lookup_answer.id'), primary_key=True, nullable=False)
category_id = Column(Integer, ForeignKey('question_category.id'), primary_key=True, nullable=False)
7 changes: 6 additions & 1 deletion api/model/lookup_answer.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from sqlalchemy import Column, Integer, Unicode
from sqlalchemy.orm import relationship

from api.model.mixins import Base, Dictable


class LookupAnswer(Base, Dictable):
__tablename__ = 'lookup_answer'

dict_collapse = True
exclude_dict_fields = ["categories"]

# dict_collapse = True

id = Column(Integer, primary_key=True)
text = Column(Unicode(500))

categories = relationship('QuestionCategory', secondary='category_answers', back_populates='answers')
2 changes: 1 addition & 1 deletion api/model/lookup_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ class LookupQuestion(Base, Dictable):
text = Column(Unicode(500))
category_id = Column(Integer, ForeignKey('question_category.id'), nullable=False)

category = relationship('QuestionCategory', lazy='joined')
category = relationship('QuestionCategory', back_populates='questions', lazy='joined')
1 change: 0 additions & 1 deletion api/model/mixins/BaseModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@

class Base(_Base):
__abstract__ = True

query = db_session.query_property()
22 changes: 15 additions & 7 deletions api/model/mixins/Dictable.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ class Dictable(object):
# Override to include pk when as_dict() is called (only considered when being nested)
dict_carry_pk = True

def as_dict(self, include_pk=True):
def _as_dict_recur(self, models_hit, include_pk=True, override_exclude_dict_fields=[]):
models_hit = models_hit + [self.__class__]

fks = [fk.parent.name for fk in self.__table__.foreign_keys]
pk = self.__table__.primary_key.columns.values()[0].name

Expand All @@ -20,21 +22,27 @@ def as_dict(self, include_pk=True):
fields = {c.name: getattr(self, c.name) for c in self.__table__.columns
if c.name not in excluded_keys}

exclude_dict_fields_list = override_exclude_dict_fields or self.exclude_dict_fields

for relationship in self.__mapper__.relationships:
rel = relationship.key
if rel not in self.exclude_dict_fields:
if rel not in exclude_dict_fields_list and relationship.entity.class_ not in models_hit:
rel_map = getattr(self, rel)
if isinstance(rel_map, InstrumentedList):
fields[rel] = [self._dict_or_collapsed(el) for el in rel_map]
fields[rel] = [self._dict_or_collapsed(el, models_hit) for el in rel_map]
else:
rel_fixed_name = rel.replace('lookup_', '') if rel.startswith('lookup_') else rel
fields[rel_fixed_name] = self._dict_or_collapsed(rel_map)
fields[rel_fixed_name] = self._dict_or_collapsed(rel_map, models_hit)

return fields

def as_dict(self, include_pk=True, override_exclude_dict_fields=[]):
return self._as_dict_recur([self.__class__], include_pk, override_exclude_dict_fields)

@staticmethod
def _dict_or_collapsed(obj):
def _dict_or_collapsed(obj, models_hit=[]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutable default arguments are bad.
This works better. You could also use an if conditional
def _dict_or_collapsed(obj, models_hit=None): models_hit = models_hit or []

if obj.dict_collapse:
return obj.as_dict(include_pk=False).popitem()[1]
return next((v for k, v in obj._as_dict_recur(models_hit=models_hit, include_pk=False).items() if
k not in obj.exclude_dict_fields), None)
else:
return obj.as_dict(include_pk=obj.dict_carry_pk)
return obj._as_dict_recur(models_hit=models_hit, include_pk=obj.dict_carry_pk)
7 changes: 7 additions & 0 deletions api/model/question_category.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from sqlalchemy import Column, Integer, Unicode
from sqlalchemy.orm import relationship

from api.model.mixins import Base, Dictable


class QuestionCategory(Base, Dictable):
__tablename__ = 'question_category'

exclude_dict_fields = ['terms', 'answers']

id = Column(Integer, primary_key=True)
text = Column(Unicode(250))

answers = relationship('LookupAnswer', secondary='category_answers', back_populates='categories')
terms = relationship('Term', secondary='term_categories', back_populates='categories')
questions = relationship('LookupQuestion', back_populates='category')
24 changes: 24 additions & 0 deletions api/model/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship

from api.model.mixins import Base, Dictable


class Response(Base, Dictable):
__tablename__ = 'responses'

id = Column(Integer, primary_key=True, nullable=False, autoincrement=True)
user_id = Column(Integer, nullable=False)
report_id = Column(Integer, ForeignKey('course.id'), nullable=False)
lookup_question_id = Column(Integer, ForeignKey('lookup_question.id'), nullable=False)
lookup_answer_id = Column(Integer, ForeignKey('lookup_answer.id'), nullable=False)

report = relationship('Course')
lookup_question = relationship('LookupQuestion')
lookup_answer = relationship('LookupAnswer')

def __init__(self, user_id, report_id, lookup_question_id, lookup_answer_id):
self.user_id = user_id
self.report_id = report_id
self.lookup_question_id = lookup_question_id
self.lookup_answer_id = lookup_answer_id
8 changes: 0 additions & 8 deletions api/model/score_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,3 @@ class ScoreData(Base, Dictable):

course = relationship('Course', back_populates='score_data', uselist=False)
questions = relationship('Question', back_populates='score_data')

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are you serializing objects now?

# Extra logic for special scores return
def as_dict(self, include_pk=True):
fields = super(ScoreData, self).as_dict(include_pk)
fields['id'] = fields.get('course', {}).get('id')
# Make cleaner
fields['comments'] = [comment._dict_or_collapsed(comment) for comment in self.course.comments]
return fields
5 changes: 5 additions & 0 deletions api/model/term.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from sqlalchemy import Column, Integer, Unicode
from sqlalchemy.orm import relationship

from api.model.mixins import Base, Dictable


class Term(Base, Dictable):
__tablename__ = 'term'

exclude_dict_fields = ['categories']

id = Column(Integer, primary_key=True)
title = Column(Unicode(200), nullable=False)

categories = relationship('QuestionCategory', secondary='term_categories', back_populates='terms')

@property
def normal_title(self):
return self.title.split(":").pop().strip().replace(' - ', ' ')
10 changes: 10 additions & 0 deletions api/model/term_categories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer, ForeignKey

from api.model.mixins import Base, Dictable


class TermCategories(Base, Dictable):
__tablename__ = 'term_categories'

term_id = Column(Integer, ForeignKey('term.id'), primary_key=True, nullable=False)
category_id = Column(Integer, ForeignKey('question_category.id'), primary_key=True, nullable=False)
37 changes: 32 additions & 5 deletions api/service/report_service.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from api.model import Course, ScoreData, Term, Instructor
from api.utils.helpers import sort_and_paginate, apply_sql_facets
import flask
from sqlalchemy.orm import load_only

from api import db_session
from api.model import Course, ScoreData, Term, Instructor, Comment, Department, Response
from api.utils.helpers import sort_and_paginate, apply_sql_facets, get_or_abort


def get_all_courses(page, page_size, order_by, facets={}):
query = Course.query
if facets:
query = apply_sql_facets(Course, query, facets)

query = query.join(Term).join(Instructor)
query = query.join(Term).join(Instructor).join(Department)

sql_results = sort_and_paginate(query, order_by, page, page_size).all()
return [obj.as_dict() for obj in sql_results]
Expand All @@ -27,5 +31,28 @@ def get_single_course(report_id):


def get_single_report(report_id):
report = ScoreData.query.filter_by(report_id=report_id).first()
return report.as_dict()
report = ScoreData.query.filter_by(report_id=report_id).join(Course).first()
if report:
result = report.as_dict()
# TODO: Clean up logic
result['comments'] = [c.text for c in
Comment.query.filter_by(report_id=report_id).with_entities(Comment.text).all()]
result['id'] = report_id
return result
else:
return None


def save_user_report_response(report_id, user_id, response_data):
get_or_abort(Course, report_id)

response_entries = []
for q_id, ans_id in response_data.items():
response_entries.append(Response(user_id, report_id, q_id, ans_id))
try:
if response_entries:
db_session.add_all(response_entries)
db_session.commit()
except Exception:
db_session.rollback()
raise
4 changes: 3 additions & 1 deletion api/service/survey_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ def get_all_questions(page, page_size, order_by, facets={}):


def get_all_categories(page, page_size, order_by):
return [c.as_dict() for c in sort_and_paginate(QuestionCategory.query, order_by, page, page_size).all()]
fields_exclude_override = [field for field in QuestionCategory.exclude_dict_fields if field != 'answers']
return [c.as_dict(override_exclude_dict_fields=fields_exclude_override) for c in
sort_and_paginate(QuestionCategory.query, order_by, page, page_size).all()]
11 changes: 11 additions & 0 deletions api/service/term_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from api.model import QuestionCategory
from api.model.term import Term
from api.utils.helpers import sort_and_paginate

Expand All @@ -9,3 +10,13 @@ def get_all_terms(page, page_size, order_by):
def get_single_term(term_id):
result = Term.query.get(term_id)
return result.as_dict() if result is not None else result


def get_single_term_categories(term_id):
term_result = Term.query.get(term_id)
if term_result:
fields_exclude_override = [field for field in QuestionCategory.exclude_dict_fields if field != 'answers']
result = [c.as_dict(override_exclude_dict_fields=fields_exclude_override) for c in term_result.categories]
else:
result = []
return result
12 changes: 11 additions & 1 deletion api/utils/helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import json, Response, request
from flask import json, Response, request, abort
from sqlalchemy_utils import sort_query


Expand Down Expand Up @@ -32,3 +32,13 @@ def responsify(results):
elif isinstance(results, list):
results_obj = {"data": results}
return Response(json.dumps(results_obj), mimetype='application/json')


def get_or_abort(model, object_id, code=404):
"""
get an object with his given id or an abort error (404 is the default)
"""
result = model.query.get(object_id)
if result is None:
abort(code)
return result