diff --git a/manage.py b/manage.py index d333c89ba..780e42ef3 100755 --- a/manage.py +++ b/manage.py @@ -7,8 +7,8 @@ from flask.ext.script.commands import ShowUrls, Clean from flask.ext.migrate import Migrate, MigrateCommand from server import create_app -from server.models import db, User, Course, Assignment, Participant, \ - Backup, Submission, Message +from server.models import db, User, Course, Assignment, Enrollment, \ + Backup, Message # default to dev config because no one should use this in # production anyway. @@ -34,22 +34,14 @@ def make_shell_context(): def make_backup(user, assign, time, messages, submit=True): backup = Backup(client_time=time, - submitter=user.id, - assignment=assign.id, submit=submit) + submitter=user, + assignment=assign, submit=submit) messages = [Message(kind=k, backup=backup, contents=m) for k, m in messages.items()] backup.messages = messages db.session.add_all(messages) db.session.add(backup) - if submit: - subm = Submission(submitter=user.id, - assignment=assign.id, backup=backup) - db.session.add(subm) - - - - @manager.command def seed(): """ Create default records for development. @@ -83,14 +75,14 @@ def seed(): make_backup(staff_member, assign2, time, messages) db.session.commit() - staff = Participant(user_id=staff_member.id, course_id=courses[0].id, + staff = Enrollment(user_id=staff_member.id, course_id=courses[0].id, role="staff") db.session.add(staff) - staff_also_student = Participant(user_id=staff_member.id, + staff_also_student = Enrollment(user_id=staff_member.id, course_id=courses[1].id, role="student") db.session.add(staff_also_student) - student_enrollment = [Participant(user_id=student.id, role="student", + student_enrollment = [Enrollment(user_id=student.id, role="student", course_id=courses[0].id) for student in students] db.session.add_all(student_enrollment) diff --git a/server/controllers/admin.py b/server/controllers/admin.py index 7c3a47f21..10657dc5e 100644 --- a/server/controllers/admin.py +++ b/server/controllers/admin.py @@ -5,7 +5,7 @@ import pytz import csv -from server.models import User, Course, Assignment, Participant, db +from server.models import User, Course, Assignment, Enrollment, db from server.constants import STAFF_ROLES, VALID_ROLES, STUDENT_ROLE import server.forms as forms @@ -139,7 +139,7 @@ def enrollment(cid): single_form = forms.EnrollmentForm(prefix="single") if single_form.validate_on_submit(): email, role = single_form.email.data, single_form.role.data - Participant.enroll_from_form(cid, single_form) + Enrollment.enroll_from_form(cid, single_form) flash("Added {email} as {role}".format(email=email, role=role), "success") query = request.args.get('query', '').strip() @@ -150,15 +150,15 @@ def enrollment(cid): find_student = User.query.filter_by(email=query) student = find_student.first() if student: - students = Participant.query.filter_by(course_id=cid, role=STUDENT_ROLE, + students = Enrollment.query.filter_by(course_id=cid, role=STUDENT_ROLE, user_id=student.id).paginate(page=page, per_page=1) else: flash("No student found with email {}".format(query), "warning") if not students: - students = Participant.query.filter_by(course_id=cid, + students = Enrollment.query.filter_by(course_id=cid, role=STUDENT_ROLE).paginate(page=page, per_page=5) - staff = Participant.query.filter(Participant.course_id == cid, - Participant.role.in_(STAFF_ROLES)).all() + staff = Enrollment.query.filter(Enrollment.course_id == cid, + Enrollment.role.in_(STAFF_ROLES)).all() return render_template('staff/course/enrollment.html', enrollments=students, staff=staff, query=query, @@ -174,7 +174,7 @@ def batch_enroll(cid): courses, current_course = get_courses(cid) batch_form = forms.BatchEnrollmentForm() if batch_form.validate_on_submit(): - new, updated = Participant.enroll_from_csv(cid, batch_form) + new, updated = Enrollment.enroll_from_csv(cid, batch_form) msg = "Added {new}, Updated {old} students".format(new=new, old=updated) flash(msg, "success") return redirect(url_for(".enrollment", cid=cid)) diff --git a/server/controllers/api.py b/server/controllers/api.py index 396d1cb40..a7425a422 100644 --- a/server/controllers/api.py +++ b/server/controllers/api.py @@ -166,15 +166,14 @@ def get(self): # TODO Permsisions for API actions -def make_backup(user, assignment, messages, submit): +def make_backup(user, assignment_id, messages, submit): """ Create backup with message objects. :param user: (object) caller - :param assignment: (int) Assignment ID + :param assignment_id: (int) Assignment ID :param messages: Data content of backup/submission :param submit: Whether this backup is a submission to be graded - :param submitter: (object) caller or submitter :return: (Backup) backup """ @@ -187,8 +186,8 @@ def make_backup(user, assignment, messages, submit): else: client_time = datetime.datetime.now() - backup = models.Backup(client_time=client_time, submitter=user.id, - assignment=assignment, submit=submit) + backup = models.Backup(client_time=client_time, submitter=user, + assignment_id=assignment_id, submit=submit) messages = [models.Message(kind=k, backup=backup, contents=m) for k, m in messages.items()] backup.messages = messages @@ -239,7 +238,7 @@ class BackupSchema(APISchema): } post_fields = { - 'email': fields.Integer, + 'email': fields.String, 'key': fields.String, 'course': fields.String, 'assign': fields.String, @@ -251,11 +250,8 @@ def __init__(self): help='Name of Assignment') self.parser.add_argument('messages', type=dict, required=True, help='Backup Contents as JSON') - - # Optional - probably not needed now that there are two endpoints - self.parser.add_argument('submit', type=bool, + self.parser.add_argument('submit', type=bool, default=False, help='Flagged as a submission') - self.parser.add_argument('submitter', help='Name of Assignment') def store_backup(self, user): args = self.parse_args() @@ -266,18 +262,6 @@ def store_backup(self, user): return backup -class SubmissionSchema(BackupSchema): - - get_fields = { - 'id': fields.Integer, - 'assignment': fields.Integer, - 'submitter': fields.Integer, - 'backup': fields.Nested(BackupSchema.get_fields), - 'flagged': fields.Boolean, - 'revision': fields.Boolean, - 'created': fields.DateTime(dt_format='rfc822') - } - class CourseSchema(APISchema): get_fields = { 'id': fields.Integer, @@ -301,6 +285,7 @@ class EnrollmentSchema(APISchema): } +# TODO: should be two classes, one for /backups/ and one for /backups// class Backup(Resource): """ Submission retreival resource. Authenticated. Permissions: >= User/Staff @@ -326,43 +311,8 @@ def post(self, user, key=None): return { 'email': current_user.email, 'key': backup.id, - 'course': backup.assign.course_id, - 'assign': backup.assignment - } - - - -class Submission(Resource): - """ Submission resource. - Authenticated. Permissions: >= Student/Staff - Used by: Ok Client Submission. - """ - model = models.Submission - schema = SubmissionSchema() - - @marshal_with(schema.get_fields) - def get(self, user, key=None): - if key is None: - restful.abort(405) - submission = self.model.query.filter_by(id=key).first() - # TODO CHECK .user obj - if submission.user != user or not user.is_admin: - restful.abort(403) - return submission - - def post(self, user, key=None): - if key is not None: - restful.abort(405) - backup = self.schema.store_backup(user) - subm = models.Submission(backup_id=backup.id, assignment=backup.assignment, - submitter=user.id) - models.db.session.add(subm) - models.db.session.commit() - return { - 'email': current_user.email, - 'key': backup.id, - 'course': backup.assign.course_id, - 'assign': backup.assignment + 'course': backup.assignment.course_id, + 'assignment': backup.assignment_id } class Score(Resource): @@ -387,7 +337,7 @@ class Enrollment(PublicResource): Authenticated. Permissions: >= User Used by: Ok Client Auth """ - model = models.Participant + model = models.Enrollment schema = EnrollmentSchema() @marshal_with(schema.get_fields) @@ -400,7 +350,6 @@ def get(self, email): api.add_resource(v3Info, '/v3') -api.add_resource(Submission, '/v3/submission', '/v3/submission/') -api.add_resource(Backup, '/v3/backup', '/v3/backup/') +api.add_resource(Backup, '/v3/backups/', '/v3/backups//') api.add_resource(Score, '/v3/score') api.add_resource(Enrollment, '/v3/enrollment/') diff --git a/server/controllers/auth.py b/server/controllers/auth.py index e0d1a2218..d44b18fcc 100644 --- a/server/controllers/auth.py +++ b/server/controllers/auth.py @@ -92,14 +92,14 @@ def use_testing_login(): return current_app.config.get('TESTING_LOGIN', False) and \ current_app.config.get('ENV') != 'prod' -@auth.route("/login") +@auth.route("/login/") def login(): """ Authenticates a user with an access token using Google APIs. """ return google_auth.authorize(callback=url_for('.authorized', _external=True)) -@auth.route('/login/authorized') +@auth.route('/login/authorized/') @google_auth.authorized_handler def authorized(resp): if resp is None or 'access_token' not in resp: @@ -118,20 +118,20 @@ def authorized(resp): # Backdoor log in if you want to impersonate a user. # Will not give you a Google auth token. # Requires that TESTING_LOGIN = True in the config and we must not be running in prod. -@auth.route('/testing-login') +@auth.route('/testing-login/') def testing_login(): if not use_testing_login(): abort(404) return render_template('testing-login.html', callback=url_for(".testing_authorized")) -@auth.route('/testing-login/authorized', methods=['POST']) +@auth.route('/testing-login/authorized/', methods=['POST']) def testing_authorized(): if not use_testing_login(): abort(404) user = user_from_email(request.form['email']) return authorize_user(user) -@auth.route("/logout") +@auth.route("/logout/") def logout(): logout_user() session.pop('google_token', None) diff --git a/server/controllers/student.py b/server/controllers/student.py index a63d7ffff..d2d85a373 100644 --- a/server/controllers/student.py +++ b/server/controllers/student.py @@ -56,13 +56,17 @@ def wrapper(*args, **kwargs): @is_enrolled def course(cid): course = Course.query.get(cid) - # TODO : Should consider group submissions as well. - user_id = current_user.id + def assignment_info(assignment): + # TODO does this make O(n) db queries? + # TODO need group info too + user_ids = assignment.active_user_ids(current_user.id) + final_submission = assignment.final_submission(user_ids) + submission_time = final_submission and final_submission.client_time + return assignment, submission_time + assignments = { - 'active': [(a, a.submission_time(user_id), a.group(user_id)) \ - for a in course.assignments if a.active], - 'inactive': [(a, a.submission_time(user_id), a.group(user_id)) \ - for a in course.assignments if not a.active] + 'active': [assignment_info(a) for a in course.assignments if a.active], + 'inactive': [assignment_info(a) for a in course.assignments if not a.active] } return render_template('student/course/index.html', course=course, **assignments) @@ -74,15 +78,11 @@ def assignment(cid, aid): assgn = Assignment.query.filter_by(id=aid, course_id=cid).one_or_none() if assgn: course = assgn.course - group = Group.lookup(current_user, assgn) - if group: - # usr_ids = [u.id for u in group.users()] - # TODO : Fetch backups from group. - pass - backups = assgn.backups(current_user.id).limit(5).all() - subms = assgn.submissions(current_user.id).limit(5).all() - flagged = any([s.flagged for s in subms]) - print(flagged) + user_ids = assgn.active_user_ids(current_user.id) + backups = assgn.backups(user_ids).limit(5).all() + subms = assgn.submissions(user_ids).limit(5).all() + final_submission = assgn.final_submission(user_ids) + flagged = final_submission and final_submission.flagged return render_template('student/assignment/index.html', course=course, assignment=assgn, backups=backups, subms=subms, flagged=flagged) else: @@ -96,10 +96,10 @@ def code(cid, aid, bid): assgn = Assignment.query.filter_by(id=aid, course_id=cid).one_or_none() if assgn: course = assgn.course - group = Group.lookup(current_user, assgn) + user_ids = assgn.active_user_ids(current_user.id) backup = Backup.query.get(bid) - if backup and backup.can_view(current_user, group, assgn): - submitter = User.query.get(backup.submitter) + if backup and backup.can_view(current_user, user_ids, course): + submitter = User.query.get(backup.submitter_id) file_contents = [m for m in backup.messages if m.kind == "file_contents"] if file_contents: diff --git a/server/models.py b/server/models.py index 71b61d67c..396218c12 100644 --- a/server/models.py +++ b/server/models.py @@ -2,7 +2,7 @@ from sqlalchemy import PrimaryKeyConstraint, MetaData from sqlalchemy.dialects import postgresql as pg from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import backref +from sqlalchemy.orm import aliased, backref from werkzeug.exceptions import BadRequest from flask.ext.login import UserMixin, AnonymousUserMixin @@ -71,6 +71,7 @@ def lookup(email): """Get a User with the given email address, or None.""" return User.query.filter_by(email=email).one_or_none() + class Course(db.Model, TimestampMixin): id = db.Column(db.Integer(), primary_key=True) offering = db.Column(db.String(), unique=True) @@ -84,7 +85,7 @@ def __repr__(self): return '' % self.offering def is_enrolled(self, user): - return Participant.query.filter_by( + return Enrollment.query.filter_by( user=user, course=self ).count() > 0 @@ -117,41 +118,53 @@ class Assignment(db.Model, TimestampMixin): def active(self): return dt.utcnow() < self.lock_date # TODO : Ensure all times are UTC - def group(self, usr_id): - # TODO merge with group.lookup - member = GroupMember.query.filter_by( - user_id=usr_id, - assignment_id=self.id - ).one_or_none() - if member: - return member.group + def active_user_ids(self, user_id): + """Return a set of the ids of all users that are active in the same group + that our user is active in. If the user is not in a group, return just + that user's id (i.e. as if they were in a 1-person group). + """ + user_member = aliased(GroupMember) + members = GroupMember.query.join( + user_member, GroupMember.group_id == user_member.group_id + ).filter( + user_member.user_id == user_id, + user_member.assignment_id == self.id, + user_member.status == 'active', + GroupMember.status == 'active' + ).all() + if not members: + return {user_id} + else: + return {member.user_id for member in members} - def backups(self, usr_id): - """Returns a query for the backups that the list of usrs has for this + def backups(self, user_ids): + """Return a query for the backups that the list of usrs has for this assignment. """ return Backup.query.filter( - Backup.submitter == usr_id, - Backup.assignment == self.id + Backup.submitter_id.in_(user_ids), + Backup.assignment_id == self.id ).order_by(Backup.client_time.desc()) - def submissions(self, usr_id): - """Returns a query for the submission that the current user has for this + def submissions(self, user_ids): + """Return a query for the submission that the current user has for this assignment. """ - return Submission.query.filter( - Submission.submitter == usr_id, - Submission.assignment == self.id - ).order_by(Submission.created.desc()) # TODO: client time - - def submission_time(self, usr_id): - """Returns the time of the most recent submission, or None.""" - most_recent = self.submissions(usr_id).first() - if most_recent: - return most_recent.backup.client_time + return Backup.query.filter( + Backup.submitter_id.in_(user_ids), + Backup.assignment_id == self.id, + Backup.submit == True + ).order_by(Backup.created.desc()) + def final_submission(self, user_ids): + """Return a final submission for a user, or None.""" + return Backup.query.filter( + Backup.submitter_id.in_(user_ids), + Backup.assignment_id == self.id, + Backup.submit == True + ).order_by(Backup.flagged.desc(), Backup.created.desc()).first() -class Participant(db.Model, TimestampMixin): +class Enrollment(db.Model, TimestampMixin): id = db.Column(db.Integer(), primary_key=True) user_id = db.Column(db.ForeignKey("user.id"), index=True, nullable=False) course_id = db.Column(db.ForeignKey("course.id"), index=True, @@ -182,7 +195,7 @@ def enroll_from_form(cid, form): db.session.add(usr) db.session.commit() role = form.role.data - Participant.create(cid, [usr.id], role) + Enrollment.create(cid, [usr.id], role) @staticmethod @transaction @@ -206,7 +219,7 @@ def enroll_from_csv(cid, form): db.session.add_all(new_users) db.session.commit() user_ids = [u.id for u in new_users] + existing_uids - Participant.create(cid, user_ids, STUDENT_ROLE) + Enrollment.create(cid, user_ids, STUDENT_ROLE) return len(new_users), len(existing_uids) @@ -215,15 +228,16 @@ def enroll_from_csv(cid, form): def create(cid, usr_ids=[], role=STUDENT_ROLE): new_records = [] for usr_id in usr_ids: - record = Participant.query.filter_by(user_id=usr_id, + record = Enrollment.query.filter_by(user_id=usr_id, course_id=cid).one_or_none() if record: record.role = role else: - record = Participant(course_id=cid, user_id=usr_id, role=role) + record = Enrollment(course_id=cid, user_id=usr_id, role=role) new_records.append(record) db.session.add_all(new_records) + class Message(db.Model, TimestampMixin): id = db.Column(db.Integer(), primary_key=True) backup = db.Column(db.ForeignKey("backup.id"), index=True) @@ -242,11 +256,14 @@ class Backup(db.Model, TimestampMixin): assign = db.relationship("Assignment") client_time = db.Column(db.DateTime()) - submitter = db.Column(db.ForeignKey("user.id"), nullable=False) - assignment = db.Column(db.ForeignKey("assignment.id"), nullable=False) + submitter_id = db.Column(db.ForeignKey("user.id"), nullable=False) + assignment_id = db.Column(db.ForeignKey("assignment.id"), nullable=False) submit = db.Column(db.Boolean(), default=False) flagged = db.Column(db.Boolean(), default=False) + submitter = db.relationship("User") + assignment = db.relationship("Assignment") + db.Index('idx_usrBackups', 'assignment', 'submitter', 'submit', 'flagged') db.Index('idx_usrFlagged', 'assignment', 'submitter', 'flagged') db.Index('idx_submittedBacks', 'assignment', 'submit') @@ -255,33 +272,19 @@ class Backup(db.Model, TimestampMixin): def as_dict(self): return {c.name: getattr(self, c.name) for c in self.__table__.columns} - def can_view(self, user, group=None, assign=None): + def can_view(self, user, member_ids, course): if user.is_admin: return True - if user.id == self.submitter: + if user.id == self.submitter_id: return True # Allow group members to view - if not group or group.assignment_id != self.assignment_id: - group = Group.lookup(user, self.assignment_id) - - if group: - member_ids = group.users() - if self.submitter in member_ids: - return True - - if not assign or assign.id != self.assignment_id: - assign = Assignment.get(self.assignment_id) - if not assign: - return False + if self.submitter_id in member_ids: + return True # Allow staff members to view - course = assign.course return user.is_enrolled(course.id, STAFF_ROLES) - - - @staticmethod def statistics(self): db.session.query(Backup).from_statement( @@ -291,23 +294,6 @@ def statistics(self): ORDER BY date_trunc('hour', backup.created)""")).all() -class Submission(db.Model, TimestampMixin): - """ A submission is created from --submit or when a backup is flagged for - grading. - - **This model may be removed. Do not depend on it for features.** - """ - id = db.Column(db.Integer(), primary_key=True) - backup_id = db.Column(db.ForeignKey("backup.id"), nullable=False) - assignment = db.Column(db.ForeignKey("assignment.id"), nullable=False) - submitter = db.Column(db.ForeignKey("user.id"), nullable=False) - flagged = db.Column(db.Boolean(), default=False) - backup = db.relationship("Backup") - user = db.relationship("User") - - db.Index('idx_flaggedSubms', 'assignment', 'submitter', 'flagged'), - - class Score(db.Model, TimestampMixin): id = db.Column(db.Integer(), primary_key=True) backup = db.Column(db.ForeignKey("backup.id"), nullable=False) diff --git a/server/templates/student/assignment/_tablehelper.html b/server/templates/student/assignment/_tablehelper.html index 46eb88cc8..610be0f37 100644 --- a/server/templates/student/assignment/_tablehelper.html +++ b/server/templates/student/assignment/_tablehelper.html @@ -36,7 +36,7 @@

{{ tname }}

{% endif %} - + @@ -68,7 +68,7 @@

{{ tname }}

- + View Code @@ -102,7 +102,7 @@

{{ tname }}

{% for backup in backups %} - + @@ -112,7 +112,7 @@

{{ tname }}

- + View Code @@ -138,7 +138,7 @@

{{tname}}

{% for subm in subms %}
- +
Submitter: {{ subm.user.email }}
@@ -156,7 +156,7 @@

{{tname}}


- + View Code @@ -182,10 +182,10 @@

{{tname}}

{% for backup in backups %}
- +
Submitter: {{ backup.user.email }}
- + View Code diff --git a/server/templates/student/course/_assigntable.html b/server/templates/student/course/_assigntable.html index 670bed118..04c662912 100644 --- a/server/templates/student/course/_assigntable.html +++ b/server/templates/student/course/_assigntable.html @@ -10,7 +10,7 @@

{{ tname }}

Due Date - {% for assgn, subm_time, group in assignments %} + {% for assgn, subm_time in assignments %} @@ -95,7 +95,7 @@

No {{ tname }}