diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b4eaea051 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +env +*.pyc +*.pyo +*.pyd +__pycache__ +.pytest_cache +*.db +*.sqlite3 \ No newline at end of file diff --git a/core/apis/assignments/__init__.py b/core/apis/assignments/__init__.py index fe4ee3c8d..54462f2df 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 \ No newline at end of file diff --git a/core/apis/assignments/principal.py b/core/apis/assignments/principal.py index e69de29bb..f8119e4d8 100644 --- a/core/apis/assignments/principal.py +++ b/core/apis/assignments/principal.py @@ -0,0 +1,34 @@ +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 get_assignments(p): + """Lists all submitted and graded assignments""" + all_submitted_and_graded_assignments = Assignment.get_all_submitted_and_graded_assignments() + all_submitted_and_graded_assignments_dump = AssignmentSchema().dump(all_submitted_and_graded_assignments, many=True) + print(all_submitted_and_graded_assignments_dump) + return APIResponse.respond(data=all_submitted_and_graded_assignments_dump) + + +@principal_assignments_resources.route('/assignments/grade', methods=['POST'], strict_slashes=False) +@decorators.accept_payload +@decorators.authenticate_principal +def grade_or_regrade_assignments(p, incoming_payload): + """Grades or regrades an assignment by the principal""" + grade_or_regrade_assignment_payload = AssignmentGradeSchema().load(incoming_payload) + graded_or_regraded_assignment = Assignment.mark_grade( + _id=grade_or_regrade_assignment_payload.id, + grade=grade_or_regrade_assignment_payload.grade, + auth_principal=p, + ) + + db.session.commit() + graded_or_regraded_assignment_dump = AssignmentSchema().dump(graded_or_regraded_assignment) + return APIResponse.respond(data=graded_or_regraded_assignment_dump) \ No newline at end of file diff --git a/core/apis/assignments/schema.py b/core/apis/assignments/schema.py index d6f4c7daf..6224f6d05 100644 --- a/core/apis/assignments/schema.py +++ b/core/apis/assignments/schema.py @@ -2,6 +2,7 @@ from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field from marshmallow_enum import EnumField from core.models.assignments import Assignment, GradeEnum +from core.models.teachers import Teacher from core.libs.helpers import GeneralObject @@ -48,4 +49,4 @@ class Meta: @post_load def initiate_class(self, data_dict, many, partial): # pylint: disable=unused-argument,no-self-use - return GeneralObject(**data_dict) + return GeneralObject(**data_dict) \ No newline at end of file diff --git a/core/apis/assignments/student.py b/core/apis/assignments/student.py index f3fb4f8c6..cd8a818e3 100644 --- a/core/apis/assignments/student.py +++ b/core/apis/assignments/student.py @@ -1,4 +1,4 @@ -from flask import Blueprint +from flask import Blueprint, jsonify from core import db from core.apis import decorators from core.apis.responses import APIResponse @@ -25,10 +25,13 @@ def upsert_assignment(p, incoming_payload): assignment = AssignmentSchema().load(incoming_payload) assignment.student_id = p.student_id - upserted_assignment = Assignment.upsert(assignment) - db.session.commit() - upserted_assignment_dump = AssignmentSchema().dump(upserted_assignment) - return APIResponse.respond(data=upserted_assignment_dump) + if assignment.content is not None: + upserted_assignment = Assignment.upsert(assignment) + db.session.commit() + upserted_assignment_dump = AssignmentSchema().dump(upserted_assignment) + return APIResponse.respond(data=upserted_assignment_dump) + else: + return jsonify(error='Content cannot be null'), 400 @student_assignments_resources.route('/assignments/submit', methods=['POST'], strict_slashes=False) 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/teachers/__init__.py b/core/apis/teachers/__init__.py index e69de29bb..1ee3e9b1e 100644 --- a/core/apis/teachers/__init__.py +++ b/core/apis/teachers/__init__.py @@ -0,0 +1 @@ +from .principal import principal_teachers_resources \ No newline at end of file diff --git a/core/apis/teachers/principal.py b/core/apis/teachers/principal.py index e69de29bb..e2daa1e15 100644 --- a/core/apis/teachers/principal.py +++ b/core/apis/teachers/principal.py @@ -0,0 +1,18 @@ +from flask import Blueprint +from core import db +from core.apis import decorators +from core.apis.responses import APIResponse +from core.models.teachers import Teacher + +from .schema import TeacherSchema +principal_teachers_resources = Blueprint('principal_teachers_resources', __name__) + + +@principal_teachers_resources.route('/teachers', methods=['GET'], strict_slashes=False) +@decorators.authenticate_principal +def list_teachers(p): + """Returns list of teachers""" + teachers_list = Teacher.get_all_teachers() + print(teachers_list[0],end=" ") + teachers_list_dump = TeacherSchema().dump(teachers_list, many=True) + return APIResponse.respond(data=teachers_list_dump) \ No newline at end of file diff --git a/core/apis/teachers/schema.py b/core/apis/teachers/schema.py index e69de29bb..59d0d3f49 100644 --- a/core/apis/teachers/schema.py +++ b/core/apis/teachers/schema.py @@ -0,0 +1,14 @@ +from marshmallow import Schema, EXCLUDE, fields, 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) \ No newline at end of file diff --git a/core/models/assignments.py b/core/models/assignments.py index 6a4d6cb5f..1e6425555 100644 --- a/core/models/assignments.py +++ b/core/models/assignments.py @@ -64,20 +64,26 @@ 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 - @classmethod 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') - + if auth_principal.teacher_id: + assertions.assert_valid(assignment.teacher_id == auth_principal.teacher_id, f'This assign was supposed to be evaluated by {assignment.teacher_id }') + assertions.assert_valid(grade is not None, 'assignment with empty grade cannot be graded') + assertions.assert_valid(assignment.state == AssignmentStateEnum.SUBMITTED, 'Only a submitted assignment can be graded') + elif auth_principal.principal_id: + assertions.assert_valid(assignment.state != AssignmentStateEnum.DRAFT, 'Only a submitted or already graded assignment can be graded') + assignment.grade = grade assignment.state = AssignmentStateEnum.GRADED db.session.flush() @@ -89,5 +95,11 @@ 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_all_submitted_and_graded_assignments(cls): + return cls.filter(cls.state == AssignmentStateEnum.SUBMITTED, cls.state == AssignmentStateEnum.GRADED).all() + + \ No newline at end of file diff --git a/core/models/teachers.py b/core/models/teachers.py index 316deae82..ade88494e 100644 --- a/core/models/teachers.py +++ b/core/models/teachers.py @@ -11,3 +11,7 @@ class Teacher(db.Model): def __repr__(self): return '' % self.id + + @classmethod + def get_all_teachers(cls): + return db.session.query(cls).all() \ No newline at end of file diff --git a/core/server.py b/core/server.py index c24eb4826..699408151 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_teachers_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_teachers_resources, url_prefix='/principal') @app.route('/') @@ -41,4 +44,4 @@ def handle_error(err): error=err.__class__.__name__, message=str(err) ), err.code - raise err + raise err \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..03525b3d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.12" + +services: + web: + build: + context: . + dockerfile: Dockerfile + image: fyle:latest + ports: + - "7755:7755" + volumes: + - .:/app + environment: + - FLASK_ENV=development + - GUNICORN_PORT=7755 + command: ["gunicorn", "-c", "gunicorn_config.py", "core.server:app"] \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 000000000..724df8cc5 --- /dev/null +++ b/dockerfile @@ -0,0 +1,15 @@ +FROM python:3.8-slim + +WORKDIR /app + +COPY . /app + +RUN pip install --no-cache-dir -r requirements.txt + +ENV FLASK_APP=core/server.py + +RUN flask db upgrade -d core/migrations/ + +EXPOSE 7755 + +CMD ["bash", "run.sh"] \ No newline at end of file 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..dacb63368 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,12 @@ -- Write query to find the number of grade A's given by the teacher who has graded the most assignments +WITH teacher as ( + SELECT teacher_id, COUNT(*) AS a_grade_count + FROM assignments + WHERE grade IS NOT NULL AND teacher_id IS NOT NULL + GROUP BY teacher_id + ORDER BY a_grade_count DESC + LIMIT 1 +) +SELECT count(*) as a_grade_count +FROM teacher JOIN assignments using(teacher_id) +WHERE grade = 'A'; \ 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..d44bfa188 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,5 @@ -- Write query to get number of graded assignments for each student: +SELECT student_id, count(*) +FROM assignments +WHERE state IS 'GRADED' +GROUP BY student_id \ No newline at end of file diff --git a/tests/assertions_test.py b/tests/assertions_test.py new file mode 100644 index 000000000..7d131987b --- /dev/null +++ b/tests/assertions_test.py @@ -0,0 +1,33 @@ +from core.libs import assertions +from core.libs.exceptions import FyleError + + +def test_assert_auth(): + try: + assertions.assert_auth(False) + except FyleError as e: + assert e.status_code == 401 + assert e.message == 'UNAUTHORIZED' + + +def test_assert_true(): + try: + assertions.assert_true(False) + except FyleError as e: + assert e.status_code == 403 + assert e.message == 'FORBIDDEN' + + +def test_assert_valid(): + try: + assertions.assert_valid(False) + except FyleError as e: + assert e.status_code == 400 + + +def test_assert_found(): + try: + assertions.assert_found(None) + except FyleError as e: + assert e.status_code == 404 + assert e.message == 'NOT_FOUND' \ No newline at end of file diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py new file mode 100644 index 000000000..460a44e61 --- /dev/null +++ b/tests/exceptions_test.py @@ -0,0 +1,14 @@ +from core.libs.exceptions import FyleError + + +def test_fyle_error(): + error = FyleError(404, 'page not found') + assert error.status_code == 404 + assert error.message == 'page not found' + + +def test_fyle_error_to_dict(): + error = FyleError(404, 'page not found') + error_dict = error.to_dict() + assert isinstance(error_dict, dict) + assert error_dict['message'] == 'page not found' \ No newline at end of file diff --git a/tests/principals_test.py b/tests/principals_test.py index da0bb1695..85a43861e 100644 --- a/tests/principals_test.py +++ b/tests/principals_test.py @@ -13,6 +13,14 @@ def test_get_assignments(client, h_principal): for assignment in data: assert assignment['state'] in [AssignmentStateEnum.SUBMITTED, AssignmentStateEnum.GRADED] +def test_get_teachers(client, h_principal): + response = client.get( + '/principal/teachers', + headers=h_principal + ) + + assert response.status_code == 200 + def test_grade_assignment_draft_assignment(client, h_principal): """ diff --git a/tests/server_test.py b/tests/server_test.py new file mode 100644 index 000000000..2193e6f82 --- /dev/null +++ b/tests/server_test.py @@ -0,0 +1,10 @@ +def test_index(client): + response = client.get("/") + assert response.status_code == 200 + assert response.json["status"] == "ready" + + +def test_invalid_endpoint(client, h_principal): + response = client.get("/other", headers=h_principal) + assert response.status_code == 404 + assert response.json["error"] == "NotFound" \ No newline at end of file diff --git a/tests/students_test.py b/tests/students_test.py index 2a1fa708d..a0f11eb18 100644 --- a/tests/students_test.py +++ b/tests/students_test.py @@ -86,3 +86,69 @@ 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_post_assignment_student_1_my(client, h_student_1): + content = 'MY TEST' + + response = client.post( + '/student/assignments', + headers=h_student_1, + json={ + 'content': content + }) + + assert response.status_code == 200 + data = response.json['data'] + assert data['content'] == content + assert data['state'] == 'DRAFT' + assert data['teacher_id'] is None + + +def test_post_assignment_student_1_update_my(client, h_student_1): + content = 'MY TEST UPDATED' + + response = client.post( + '/student/assignments', + headers=h_student_1, + json={ + 'id': 7, + 'content': content + }) + + try: + assert response.status_code == 200 + data = response.json['data'] + assert data['content'] == content + assert data['state'] == 'DRAFT' + assert data['teacher_id'] is None + except AssertionError: + assert response.status_code == 400 + data = response.json + assert data['error'] == 'FyleError' + assert data['message'] == 'only assignment in draft state can be edited' + + +def test_submit_assignment_student_1_my(client, h_student_1): + """ + can be failure case: only a draft assignment can be submitted ( an assignment can't be submitted more than once. ) + """ + response = client.post( + '/student/assignments/submit', + headers=h_student_1, + json={ + 'id': 7, + 'teacher_id': 1 + }) + + try: + assert response.status_code == 200 + data = response.json['data'] + assert data['student_id'] == 1 + assert data['state'] == 'SUBMITTED' + assert data['teacher_id'] == 1 + except AssertionError: + assert response.status_code == 400 + data = response.json + assert data['error'] == 'FyleError' + assert data['message'] == 'only a draft assignment can be submitted' \ No newline at end of file diff --git a/tests/teachers_test.py b/tests/teachers_test.py index 8b5c818c5..ffb4c37e1 100644 --- a/tests/teachers_test.py +++ b/tests/teachers_test.py @@ -1,3 +1,5 @@ +from core.models.assignments import GradeEnum, AssignmentStateEnum + def test_get_assignments_teacher_1(client, h_teacher_1): response = client.get( '/teacher/assignments', @@ -34,7 +36,7 @@ def test_grade_assignment_cross(client, h_teacher_2): headers=h_teacher_2, json={ "id": 1, - "grade": "A" + "grade": GradeEnum.A.value } ) @@ -72,7 +74,7 @@ def test_grade_assignment_bad_assignment(client, h_teacher_1): headers=h_teacher_1, json={ "id": 100000, - "grade": "A" + "grade": GradeEnum.A.value } ) @@ -91,7 +93,7 @@ def test_grade_assignment_draft_assignment(client, h_teacher_1): headers=h_teacher_1 , json={ "id": 2, - "grade": "A" + "grade": GradeEnum.A.value } ) @@ -99,3 +101,26 @@ def test_grade_assignment_draft_assignment(client, h_teacher_1): data = response.json assert data['error'] == 'FyleError' + +def test_grade_assignment_by_teacher_my(client, h_teacher_1): + """ + can be failure case: an assignment can't be graded more than once by a teacher + """ + response = client.post( + '/teacher/assignments/grade', + headers=h_teacher_1, + json={ + "id": 7, + "grade": GradeEnum.A.value + } + ) + + try: + assert response.status_code == 200 + assert response.json['data']['state'] == AssignmentStateEnum.GRADED.value + assert response.json['data']['grade'] == GradeEnum.A + except AssertionError: + assert response.status_code == 400 + data = response.json + assert data['error'] == 'FyleError' + assert data['message'] == 'Assignment is already graded' \ No newline at end of file