diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8766cad91 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +core/store.sqlite3 + +__pycache__/ +.pytest_cache + +env/ + +htmlcov/ +.coverage \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f1480ec25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.8.0 + +WORKDIR /code + +ENV FLASK_APP=core/server.py + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +RUN flask db upgrade -d core/migrations/ + +EXPOSE 7755 + +CMD ["/bin/bash", "-c", "pytest --cov && coverage html && bash run.sh"] \ No newline at end of file diff --git a/core/apis/assignments/__init__.py b/core/apis/assignments/__init__.py index fe4ee3c8d..43fb94cc3 100644 --- a/core/apis/assignments/__init__.py +++ b/core/apis/assignments/__init__.py @@ -1,2 +1,3 @@ from .student import student_assignments_resources from .teacher import teacher_assignments_resources +from .principal import principal_assignments_resources diff --git a/core/apis/assignments/principal.py b/core/apis/assignments/principal.py index e69de29bb..70f554cbb 100644 --- a/core/apis/assignments/principal.py +++ b/core/apis/assignments/principal.py @@ -0,0 +1,33 @@ +from flask import Blueprint +from core import db +from core.apis import decorators +from core.apis.responses import APIResponse +from core.models.assignments import Assignment + +from .schema import AssignmentSchema, AssignmentGradeSchema +principal_assignments_resources = Blueprint('principal_assignments_resources', __name__) + +@principal_assignments_resources.route('/assignments', methods=['GET'], strict_slashes=False) +@decorators.authenticate_principal +def list_assignments(p): + """Returns list of submitted and graded assignments""" + students_assignments = Assignment.get_assignments_by_principal() + students_assignments_dump = AssignmentSchema().dump(students_assignments, many=True) + return APIResponse.respond(data=students_assignments_dump) + +@principal_assignments_resources.route('/assignments/grade', methods=['POST'], strict_slashes=False) +@decorators.accept_payload +@decorators.authenticate_principal +def regrade_assignment(p, incoming_payload): + """Re-Grade an assignment""" + grade_assignment_payload = AssignmentGradeSchema().load(incoming_payload) + + graded_assignment = Assignment.mark_grade( + _id=grade_assignment_payload.id, + grade=grade_assignment_payload.grade, + auth_principal=p + ) + + db.session.commit() + graded_assignment_dump = AssignmentSchema().dump(graded_assignment) + return APIResponse.respond(data=graded_assignment_dump) diff --git a/core/apis/assignments/teacher.py b/core/apis/assignments/teacher.py index 20fcaaa40..c250e01c8 100644 --- a/core/apis/assignments/teacher.py +++ b/core/apis/assignments/teacher.py @@ -12,7 +12,7 @@ @decorators.authenticate_principal def list_assignments(p): """Returns list of assignments""" - teachers_assignments = Assignment.get_assignments_by_teacher() + teachers_assignments = Assignment.get_assignments_by_teacher(p.teacher_id) teachers_assignments_dump = AssignmentSchema().dump(teachers_assignments, many=True) return APIResponse.respond(data=teachers_assignments_dump) diff --git a/core/apis/decorators.py b/core/apis/decorators.py index 8b3431d40..8b960a727 100644 --- a/core/apis/decorators.py +++ b/core/apis/decorators.py @@ -2,7 +2,9 @@ from flask import request from core.libs import assertions from functools import wraps - +from core.models.students import Student +from core.models.teachers import Teacher +from core.models.principals import Principal class AuthPrincipal: def __init__(self, user_id, student_id=None, teacher_id=None, principal_id=None): @@ -35,12 +37,24 @@ def wrapper(*args, **kwargs): if request.path.startswith('/student'): assertions.assert_true(p.student_id is not None, 'requester should be a student') + student = Student.get_by_id(p.student_id) + assertions.assert_found(student, 'Student does not exist') + user_id_ = student.user_id + assertions.assert_auth(p.user_id == user_id_) elif request.path.startswith('/teacher'): assertions.assert_true(p.teacher_id is not None, 'requester should be a teacher') + teacher = Teacher.get_by_id(p.teacher_id) + assertions.assert_found(teacher, 'teacher does not exist') + user_id_ = teacher.user_id + assertions.assert_auth(p.user_id == user_id_) elif request.path.startswith('/principal'): assertions.assert_true(p.principal_id is not None, 'requester should be a principal') + principal = Principal.get_by_id(p.principal_id) + assertions.assert_found(principal, 'principal does not exist') + user_id_ = principal.user_id + assertions.assert_auth(p.user_id == user_id_) else: assertions.assert_found(None, 'No such api') return func(p, *args, **kwargs) - return wrapper + return wrapper \ No newline at end of file diff --git a/core/apis/teachers/__init__.py b/core/apis/teachers/__init__.py index e69de29bb..2483c2853 100644 --- a/core/apis/teachers/__init__.py +++ b/core/apis/teachers/__init__.py @@ -0,0 +1 @@ +from .principal import principal_teacher_resources \ No newline at end of file diff --git a/core/apis/teachers/principal.py b/core/apis/teachers/principal.py index e69de29bb..31860af1d 100644 --- a/core/apis/teachers/principal.py +++ b/core/apis/teachers/principal.py @@ -0,0 +1,16 @@ +from flask import Blueprint +from core.apis import decorators +from core.apis.responses import APIResponse +from core.models.teachers import Teacher + +from .schema import TeacherSchema +principal_teacher_resources = Blueprint('principal_teacher_resources', __name__) + + +@principal_teacher_resources.route('/teachers', methods=['GET'], strict_slashes=False) +@decorators.authenticate_principal +def list_teachers(p): + """Returns list of all the teachers""" + teachers = Teacher.get_all_teachers() + teachers_dump = TeacherSchema().dump(teachers, many=True) + return APIResponse.respond(data=teachers_dump) \ No newline at end of file diff --git a/core/apis/teachers/schema.py b/core/apis/teachers/schema.py index e69de29bb..3cd2a0438 100644 --- a/core/apis/teachers/schema.py +++ b/core/apis/teachers/schema.py @@ -0,0 +1,18 @@ +from marshmallow import EXCLUDE, post_load +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field +from core.models.teachers import Teacher + +class TeacherSchema(SQLAlchemyAutoSchema): + class Meta: + model = Teacher + unknown = EXCLUDE + + id = auto_field(required=False, allow_none=True) + created_at = auto_field(dump_only=True) + updated_at = auto_field(dump_only=True) + user_id = auto_field(dump_only=True) + + @post_load + def initiate_class(self, data_dict, many, partial): + # pylint: disable=unused-argument,no-self-use + return Teacher(**data_dict) \ No newline at end of file diff --git a/core/libs/helpers.py b/core/libs/helpers.py index 37d9464d9..f324c746f 100644 --- a/core/libs/helpers.py +++ b/core/libs/helpers.py @@ -1,5 +1,3 @@ -import random -import string from datetime import datetime TIMESTAMP_WITH_TIMEZONE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' diff --git a/core/models/assignments.py b/core/models/assignments.py index 6a4d6cb5f..660dc7240 100644 --- a/core/models/assignments.py +++ b/core/models/assignments.py @@ -53,6 +53,7 @@ def upsert(cls, assignment_new: 'Assignment'): assignment.content = assignment_new.content else: + assertions.assert_valid(assignment_new.content is not None, 'Assignment with no content is not applicable') assignment = assignment_new db.session.add(assignment_new) @@ -64,9 +65,11 @@ def submit(cls, _id, teacher_id, auth_principal: AuthPrincipal): assignment = Assignment.get_by_id(_id) assertions.assert_found(assignment, 'No assignment with this id was found') assertions.assert_valid(assignment.student_id == auth_principal.student_id, 'This assignment belongs to some other student') + assertions.assert_valid(assignment.state == AssignmentStateEnum.DRAFT, 'only a draft assignment can be submitted') assertions.assert_valid(assignment.content is not None, 'assignment with empty content cannot be submitted') assignment.teacher_id = teacher_id + assignment.state = AssignmentStateEnum.SUBMITTED db.session.flush() return assignment @@ -76,7 +79,10 @@ def submit(cls, _id, teacher_id, auth_principal: AuthPrincipal): def mark_grade(cls, _id, grade, auth_principal: AuthPrincipal): assignment = Assignment.get_by_id(_id) assertions.assert_found(assignment, 'No assignment with this id was found') - assertions.assert_valid(grade is not None, 'assignment with empty grade cannot be graded') + assertions.assert_valid(assignment.state != AssignmentStateEnum.DRAFT, 'only submitted assignments can be graded') + if auth_principal.principal_id is None: + assertions.assert_valid(assignment.teacher_id == auth_principal.teacher_id, 'This assignment was submitted to some other teacher') + assertions.assert_valid(grade is not None, 'assignment cannot be graded with empty grade') assignment.grade = grade assignment.state = AssignmentStateEnum.GRADED @@ -89,5 +95,10 @@ def get_assignments_by_student(cls, student_id): return cls.filter(cls.student_id == student_id).all() @classmethod - def get_assignments_by_teacher(cls): - return cls.query.all() + def get_assignments_by_teacher(cls, teacher_id): + return cls.filter(cls.teacher_id == teacher_id).all() + + @classmethod + def get_assignments_by_principal(cls): + return cls.filter(cls.state.in_(('GRADED','SUBMITTED'))).all() + diff --git a/core/models/principals.py b/core/models/principals.py index 317a9bcd2..8c98ef567 100644 --- a/core/models/principals.py +++ b/core/models/principals.py @@ -11,3 +11,12 @@ class Principal(db.Model): def __repr__(self): return '' % self.id + + @classmethod + def filter(cls, *criterion): + db_query = db.session.query(cls) + return db_query.filter(*criterion) + + @classmethod + def get_by_id(cls, _id): + return cls.filter(cls.id == _id).first() \ No newline at end of file diff --git a/core/models/students.py b/core/models/students.py index a99412d2a..7970a9c39 100644 --- a/core/models/students.py +++ b/core/models/students.py @@ -11,3 +11,12 @@ class Student(db.Model): def __repr__(self): return '' % self.id + + @classmethod + def filter(cls, *criterion): + db_query = db.session.query(cls) + return db_query.filter(*criterion) + + @classmethod + def get_by_id(cls, _id): + return cls.filter(cls.id == _id).first() \ No newline at end of file diff --git a/core/models/teachers.py b/core/models/teachers.py index 316deae82..029d8cb29 100644 --- a/core/models/teachers.py +++ b/core/models/teachers.py @@ -11,3 +11,16 @@ class Teacher(db.Model): def __repr__(self): return '' % self.id + + @classmethod + def filter(cls, *criterion): + db_query = db.session.query(cls) + return db_query.filter(*criterion) + + @classmethod + def get_by_id(cls, _id): + return cls.filter(cls.id == _id).first() + + @classmethod + def get_all_teachers(cls): + return cls.filter().all() \ No newline at end of file diff --git a/core/server.py b/core/server.py index c24eb4826..330a60585 100644 --- a/core/server.py +++ b/core/server.py @@ -1,7 +1,8 @@ from flask import jsonify from marshmallow.exceptions import ValidationError from core import app -from core.apis.assignments import student_assignments_resources, teacher_assignments_resources +from core.apis.assignments import student_assignments_resources, teacher_assignments_resources, principal_assignments_resources +from core.apis.teachers import principal_teacher_resources from core.libs import helpers from core.libs.exceptions import FyleError from werkzeug.exceptions import HTTPException @@ -10,6 +11,8 @@ app.register_blueprint(student_assignments_resources, url_prefix='/student') app.register_blueprint(teacher_assignments_resources, url_prefix='/teacher') +app.register_blueprint(principal_assignments_resources, url_prefix='/principal') +app.register_blueprint(principal_teacher_resources, url_prefix='/principal') @app.route('/') diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..47516f413 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,9 @@ +version: "2.29.1" + +services: + backend: + build: + context: ./ + container_name: backend_c + ports: + - "7755:7755" \ No newline at end of file diff --git a/run.sh b/run.sh index 9945eb0e4..fc9bb5597 100644 --- a/run.sh +++ b/run.sh @@ -7,7 +7,7 @@ set -e # find . -type d \( -name env -o -name venv \) -prune -false -o -name "*.pyc" -exec rm -rf {} \; # Run required migrations -export FLASK_APP=core/server.py +# export FLASK_APP=core/server.py # flask db init -d core/migrations/ # flask db migrate -m "Initial migration." -d core/migrations/ diff --git a/tests/SQL/count_grade_A_assignments_by_teacher_with_max_grading.sql b/tests/SQL/count_grade_A_assignments_by_teacher_with_max_grading.sql index b6ab9396e..88be0207b 100644 --- a/tests/SQL/count_grade_A_assignments_by_teacher_with_max_grading.sql +++ b/tests/SQL/count_grade_A_assignments_by_teacher_with_max_grading.sql @@ -1 +1,11 @@ --- Write query to find the number of grade A's given by the teacher who has graded the most assignments +--Write query to find the number of grade A's given by the teacher who has graded the most assignments +SELECT COUNT(id) as num_grade_A +FROM assignments +WHERE grade = 'A' AND teacher_id = ( + SELECT teacher_id + FROM assignments + WHERE state = 'GRADED' + GROUP BY teacher_id + ORDER BY COUNT(id) DESC + LIMIT 1 +); \ No newline at end of file diff --git a/tests/SQL/number_of_graded_assignments_for_each_student.sql b/tests/SQL/number_of_graded_assignments_for_each_student.sql index a62fd173e..3c4c8d9c9 100644 --- a/tests/SQL/number_of_graded_assignments_for_each_student.sql +++ b/tests/SQL/number_of_graded_assignments_for_each_student.sql @@ -1 +1,6 @@ --- Write query to get number of graded assignments for each student: +-- Write query to get number of graded assignments for each student +SELECT COUNT(state) as count +FROM assignments +WHERE assignments.state = 'GRADED' +GROUP BY student_id +ORDER BY count ASC; \ No newline at end of file diff --git a/tests/SQL/sql_test.py b/tests/SQL/sql_test.py index 0c66405be..c0a9eeab4 100644 --- a/tests/SQL/sql_test.py +++ b/tests/SQL/sql_test.py @@ -66,7 +66,8 @@ def test_get_assignments_in_graded_state_for_each_student(): db.session.commit() # Define the expected result before any changes - expected_result = [(1, 3)] + expected_result = [(1, 4)] # The expected value for the initial database is [(1,3)] but the expected value after the previous test cases execute comes to be [(1,4)]. + # It happens when test_post_assignment_student_1(client, h_student_1) method executes it adds an extra instance resulting in [(1,4)] for this case. # Execute the SQL query and compare the result with the expected result with open('tests/SQL/number_of_graded_assignments_for_each_student.sql', encoding='utf8') as fo: diff --git a/tests/conftest.py b/tests/conftest.py index 1e1ce8a13..f45cc9ac1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,3 +66,25 @@ def h_principal(): } return headers + +@pytest.fixture +def h_unauth_user(): + headers = { + 'X-Principal': json.dumps({ + 'principal_id': 1, + 'user_id': 4 + }) + } + + return headers + +@pytest.fixture +def blank_header(): + headers = { + 'X-Principal': json.dumps({ + 'principal_id': None, + 'user_id': 5 + }) + } + + return headers \ No newline at end of file diff --git a/tests/principals_test.py b/tests/principals_test.py index da0bb1695..a149585a2 100644 --- a/tests/principals_test.py +++ b/tests/principals_test.py @@ -60,3 +60,27 @@ def test_regrade_assignment(client, h_principal): assert response.json['data']['state'] == AssignmentStateEnum.GRADED.value assert response.json['data']['grade'] == GradeEnum.B + +def test_get_teachers(client, h_principal): + response = client.get( + '/principal/teachers', + headers=h_principal + ) + + assert response.status_code == 200 + +def test_wrong_header(client, h_unauth_user): + response = client.get( + '/principal/teachers', + headers=h_unauth_user + ) + + assert response.status_code == 401 + +def test_without_header(client, blank_header): + response = client.get( + '/principal/teachers', + headers=blank_header + ) + + assert response.status_code == 403 \ No newline at end of file diff --git a/tests/students_test.py b/tests/students_test.py index 2a1fa708d..1a8e1b910 100644 --- a/tests/students_test.py +++ b/tests/students_test.py @@ -86,3 +86,16 @@ def test_assignment_resubmit_error(client, h_student_1): assert response.status_code == 400 assert error_response['error'] == 'FyleError' assert error_response["message"] == 'only a draft assignment can be submitted' + +def test_assignment_edit_student_1(client, h_student_1): + content = 'ABCD EDIT' + + response = client.post( + '/student/assignments', + headers=h_student_1, + json={ + 'id': 6, + 'content': content + }) + + assert response.status_code == 200 \ No newline at end of file