Skip to content

Commit

Permalink
Merge pull request #19 from InbarShirizly/testing-and-code-architecture
Browse files Browse the repository at this point in the history
Testing and code architecture
  • Loading branch information
ItayS14 authored Oct 4, 2020
2 parents a3056c2 + bfe8619 commit 2662aa7
Show file tree
Hide file tree
Showing 25 changed files with 262 additions and 173 deletions.
4 changes: 2 additions & 2 deletions Server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ API doc can be found [here](https://documenter.getpostman.com/view/4335694/TVRg6
API will be running now at: `http://localhost:5000`

## Project structure
The server code is inside Server package, and can be runned with the external module `run.py`.
The server code is inside server package, and can be runned with the external module `run.py`.
We decided to use `flask-restful` extenstion in order to create the endpoints, and `flaks-sqlalchemy` in order to create the database.

The inside the `Server` package is organzied into the following sub-packages:
The inside the `server` package is organzied into the following sub-packages:

- `api` - package which is responsible for the api endpoints
- `utils` - general utils for the application
Expand Down
4 changes: 2 additions & 2 deletions Server/Server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from Server.config import FlaskConfig
from server.config import FlaskConfig
from flask_httpauth import HTTPBasicAuth

app = Flask(__name__)
Expand All @@ -12,7 +12,7 @@
auth = HTTPBasicAuth(app)


from Server.api import api_blueprint
from server.api import api_blueprint

app.register_blueprint(api_blueprint)

2 changes: 1 addition & 1 deletion Server/Server/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
api_blueprint = Blueprint('api', __name__, url_prefix='/api')
api = Api(api_blueprint)

from Server.api import auth, clasrooms, reports, student_status
from server.api import auth, clasrooms, reports, student_status
Binary file removed Server/Server/api/__pycache__/__init__.cpython-38.pyc
Binary file not shown.
Binary file removed Server/Server/api/__pycache__/auth.cpython-38.pyc
Binary file not shown.
Binary file not shown.
Binary file removed Server/Server/api/__pycache__/reports.cpython-38.pyc
Binary file not shown.
26 changes: 14 additions & 12 deletions Server/Server/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
from Server import auth, bcrypt, db
from server import auth, bcrypt, db
from flask_restful import Resource, reqparse, abort
from Server.api import api
from Server.models import get_user, TeacherModel


register_argparse = reqparse.RequestParser()
register_argparse.add_argument("username", type=str, help="Username is required", location='json', required=True)
register_argparse.add_argument("email", type=str, help="Email is required", location='json', required=True)
register_argparse.add_argument("password", type=str, help="Password is required", location='json', required=True)
from server.api import api
from server.models.orm import get_user, TeacherModel
from server.config import RestErrors


class RegisterResource(Resource):
def __init__(self):
super().__init__()
self._post_args = reqparse.RequestParser()
self._post_args.add_argument("username", type=str, help="Username is required", location='json', required=True)
self._post_args.add_argument("email", type=str, help="Email is required", location='json', required=True)
self._post_args.add_argument("password", type=str, help="Password is required", location='json', required=True)

def post(self):
args = register_argparse.parse_args()
args = self._post_args.parse_args()
if TeacherModel.query.filter_by(username=args['username']).first():
return abort(400, message="Username already taken")
return abort(400, message=RestErrors.USERNAME_TAKEN)
if TeacherModel.query.filter_by(email=args['email']).first():
return abort(400, message="Email already taken")
return abort(400, message=RestErrors.EMAIL_TAKEN)

user = TeacherModel(
username=args['username'],
Expand Down
65 changes: 25 additions & 40 deletions Server/Server/api/clasrooms.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,41 @@
from Server.api import api
from flask_restful import Resource, reqparse, abort, fields, marshal
from Server import auth, db
from Server.models import TeacherModel, ClassroomModel
from server.api import api
from flask_restful import Resource, reqparse, abort, marshal
from server import auth, db
from server.models.orm import TeacherModel, ClassroomModel
from werkzeug.datastructures import FileStorage
from Server.utils import parser
from Server.utils.utils import create_students_df
from server.parsing import parser
from server.parsing.utils import create_students_df
import pandas as pd

# Fields:
classrooms_list_fields = { # Fields list of classrooms
'name': fields.String,
'id': fields.Integer
}
class StudentItemField(fields.Raw): # Custom field to parse StudentModel object

def format(self, value):
without_none = {k: v for k, v in value.__dict__ .items() if v is not None} # Getting only attributes which are not None
del without_none['_sa_instance_state']
del without_none['class_id']
return without_none

classroom_resource_fields = { # Fields for a single classroom
'name': fields.String,
'students': fields.List(StudentItemField)
}


# arg parsing:
classrooms_post_argparse = reqparse.RequestParser()
classrooms_post_argparse.add_argument('name', type=str, help="Name of the class is required", required=True)
classrooms_post_argparse.add_argument('students_file', type=FileStorage, location='files', help="Student file is required", required=True)

classroom_put_argparse = reqparse.RequestParser()
classroom_put_argparse.add_argument('new_name', type=str, help="New name is required in order to update", location="json", required=True)
from server.config import RestErrors
from server.models.marshals import classrooms_list_fields, classroom_resource_fields


class ClassroomsResource(Resource):
method_decorators = [auth.login_required]

def __init__(self):
super().__init__()

self._post_args = reqparse.RequestParser()
self._post_args.add_argument('name', type=str, help="Name of the class is required", required=True)
self._post_args.add_argument('students_file', type=FileStorage, location='files', help="Student file is required", required=True)

self._put_args = reqparse.RequestParser()
self._put_args.add_argument('new_name', type=str, help="New name is required in order to update", location="json", required=True)

def get(self, class_id=None):
if class_id is None:
return marshal(auth.current_user().classrooms, classrooms_list_fields)

current_class = ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() # Making sure the class belongs to the current user
if current_class is None:
abort(400, message="Invalid class id")
abort(400, message=RestErrors.INVALID_CLASS)
return marshal(current_class, classroom_resource_fields)

def post(self, class_id=None):
if class_id:
return abort(404, message="Invalid route")
args = classrooms_post_argparse.parse_args()
return abort(404, message=RestErrors.INVALID_ROUTE)
args = self._post_args.parse_args()
filename, stream = args['students_file'].filename.replace('"', ""), args['students_file'].stream #TODO: replace here because of postman post request
students_df = create_students_df(filename, stream)
students = parser.parse_df(students_df)
Expand All @@ -65,11 +50,11 @@ def post(self, class_id=None):

def put(self, class_id=None):
if class_id is None:
return abort(404, message="Invalid route")
args = classroom_put_argparse.parse_args()
return abort(404, message=RestErrors.INVALID_ROUTE)
args = self._put_args.parse_args()
current_class = ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() # Making sure the class belongs to the current user
if current_class is None:
abort(400, message="Invalid class id")
abort(400, message=RestErrors.INVALID_CLASS)
current_class.name = args['new_name']
db.session.commit()
return "", 204
Expand All @@ -85,7 +70,7 @@ def delete(self, class_id=None):

current_class = ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() # Making sure the class belongs to the current user
if current_class is None:
abort(400, message="Invalid class id")
abort(400, message=RestErrors.INVALID_CLASS)

db.session.delete(current_class)
db.session.commit()
Expand Down
67 changes: 26 additions & 41 deletions Server/Server/api/reports.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,47 @@
from Server.api import api
from flask_restful import Resource, reqparse, abort, marshal, fields
from Server.utils.attendance_check import Attendance
from server.api import api
from flask_restful import Resource, reqparse, abort, marshal
from server.parsing.attendance_check import Attendance
from werkzeug.datastructures import FileStorage
from Server import db, auth
from server import db, auth
from datetime import datetime
import pandas as pd
from Server.models import StudentModel, ClassroomModel, ReportModel, SessionModel, ZoomNamesModel, StudentStatus
from Server.utils.utils import create_chat_df


# marshals:
reports_list_fields = { # Fields list of classrooms
'description': fields.String,
'id': fields.Integer
}

student_status_field = {
'status': fields.Integer,
'student_name': fields.String(attribute='student.name'),
'status_id': fields.Integer(attribute="id")
}

# args:
report_post_args = reqparse.RequestParser()
report_post_args.add_argument('description', type=str)
report_post_args.add_argument('chat_file', type=FileStorage, help="Chat file is required", location='files', required=True)
report_post_args.add_argument('time_delta', default=1, type=int)
report_post_args.add_argument('date', default=datetime.now().date(), type=lambda x: datetime.strptime(x, '%d/%m/%y'))
report_post_args.add_argument('first_sentence', type=str, help='First sentence is required in order to understand when does the check starts', required=True)
report_post_args.add_argument('not_included_zoom_users', default=[], type=str, help='Must be a list of strings with zoom names', action="append")
from server.models.orm import StudentModel, ClassroomModel, ReportModel, SessionModel, ZoomNamesModel, StudentStatus
from server.parsing.utils import create_chat_df
from server.api.utils import validate_classroom
from server.config import RestErrors
from server.models.marshals import student_status_field, reports_list_fields


class ReportsResource(Resource):
method_decorators = [auth.login_required]
method_decorators = [validate_classroom, auth.login_required]

def get(self, class_id, report_id=None): # TODO: create decorator that validates class_id
if ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() is None:
abort(400, message="Invalid class id")
def __init__(self):
super().__init__()
self._post_args = reqparse.RequestParser()
self._post_args.add_argument('description', type=str)
self._post_args.add_argument('chat_file', type=FileStorage, help="Chat file is required", location='files', required=True)
self._post_args.add_argument('time_delta', default=1, type=int)
self._post_args.add_argument('date', default=datetime.now().date(), type=lambda x: datetime.strptime(x, '%d/%m/%y'))
self._post_args.add_argument('first_sentence', type=str, help='First sentence is required in order to understand when does the check starts', required=True)
self._post_args.add_argument('not_included_zoom_users', default=[], type=str, help='Must be a list of strings with zoom names', action="append")

def get(self, class_id, report_id=None):
if report_id is None:
return marshal(ReportModel.query.filter_by(class_id=class_id).all(), reports_list_fields)
report = ReportModel.query.filter_by(class_id=class_id, id=report_id).first()
if report is None:
abort(400, message="Invalid report id")
abort(400, message=RestErrors.INVALID_REPORT)
return marshal(report.student_statuses, student_status_field)

def post(self, class_id, report_id=None):
args = report_post_args.parse_args()
if ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() is None:
abort(400, message="Invalid class id")
if report_id:
abort(404, message="Invalid route")
abort(404, message=RestErrors.INVALID_REPORT)
args = self._post_args.parse_args()

students_df = pd.read_sql(StudentModel.query.filter_by(class_id=class_id).statement, con=db.engine)


chat_file = args['chat_file'].stream.read().decode("utf-8").split("\n")
chat_file = args['chat_file'].stream.read().decode("utf-8").split("\n") #TODO: check this in test
chat_df = create_chat_df(chat_file)
report_object = Attendance(chat_df, students_df, ['name', "id_number", "phone"], args['time_delta'], args['first_sentence'], args['not_included_zoom_users'])

Expand Down Expand Up @@ -84,8 +71,6 @@ def post(self, class_id, report_id=None):
return {"report_id": new_report.id}

def delete(self, class_id, report_id=None):
if ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() is None:
abort(400, message="Invalid class id")
if report_id is None: # Deleting all reports of class
class_reports_id = db.session.query(ReportModel.id).filter_by(class_id=class_id).all()
for report_data in class_reports_id:
Expand All @@ -96,7 +81,7 @@ def delete(self, class_id, report_id=None):

current_report = ReportModel.query.filter_by(id=report_id).first() # Making sure the class belongs to the current user
if current_report is None:
abort(400, message="Invalid report id")
abort(400, message=RestErrors.INVALID_REPORT)

db.session.delete(current_report)
db.session.commit()
Expand Down
25 changes: 16 additions & 9 deletions Server/Server/api/student_status.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
from Server.api import api
from server.api import api
from flask_restful import Resource, reqparse, abort
from Server import db, auth
from Server.models import StudentStatus
from server import db, auth
from server.models.orm import StudentStatus
from server.config import RestErrors

STATUS_CHOICES = (0, 1, 2)

# arg parsing:
student_status_argparse = reqparse.RequestParser()
student_status_argparse.add_argument('new_status', type=int,
help=f"Bad choice, please pick one of this choices {STATUS_CHOICES}",
required=True, location="json", choices=STATUS_CHOICES)

class StudentStatusResource(Resource):
method_decorators = [auth.login_required]

def __init__(self):
super().__init__()

self._put_args = reqparse.RequestParser()
self._put_args.add_argument(
'new_status', type=int,
help=f"Bad choice, please pick one of this choices {STATUS_CHOICES}",
required=True, location="json", choices=STATUS_CHOICES
)

def put(self, status_id):
args = student_status_argparse.parse_args()
args = self._put_args.parse_args()
status = StudentStatus.query.get(status_id)
if status is None or status.report.classroom.teacher != auth.current_user():
abort(400, message="Invalid status id")
abort(400, message=RestErrors.INVALID_STATUS)

status.status = args["new_status"]
db.session.commit()
Expand Down
20 changes: 20 additions & 0 deletions Server/Server/api/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from functools import wraps
from flask_restful import abort
from server import auth
from server.models.orm import ClassroomModel
from server import app
from server.config import RestErrors

# This decorator fucntion will make sure that the classroom belongs to the current user
def validate_classroom(fnc):
def inner(class_id, report_id=None):
if ClassroomModel.query.filter_by(id=class_id, teacher=auth.current_user()).first() is None:
abort(400, message="Invalid class id")
return fnc(class_id, report_id)
return inner


# Error handler for 404
@app.errorhandler(404)
def not_found(error):
return {"message": RestErrors.INVALID_ROUTE}
Empty file added Server/Server/api/validators.py
Empty file.
12 changes: 10 additions & 2 deletions Server/Server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ class FlaskConfig:


class ParseConfig:

FILE_COLS_DICT = {
"name": ["שם התלמיד", "תלמידים", "שמות", "שם", "סטודנט"],
"id_number": ["תעודת זהות", "ת.ז.", "ת.ז", "תז"],
Expand All @@ -13,4 +12,13 @@ class ParseConfig:
"org_class": ["כיתה"]
}
MASHOV_COLS = ["name", "org_class"]
GENDER_DICT = {1: ["זכר", "ז", "(ז)"], 0: ["נקבה", "נ", "(נ)"]}
GENDER_DICT = {1: ["זכר", "ז", "(ז)"], 0: ["נקבה", "נ", "(נ)"]}


class RestErrors:
INVALID_ROUTE = "Route does't exist"
INVALID_CLASS = "Invalid class id"
INVALID_REPORT = "Invalid report id"
INVALID_STATUS = "Invalid status id"
USERNAME_TAKEN = "Username already taken"
EMAIL_TAKEN = "Email already taken"
9 changes: 9 additions & 0 deletions Server/Server/models/custom_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from flask_restful import fields


class StudentItemField(fields.Raw): # Custom field to parse StudentModel object
def format(self, value):
without_none = {k: v for k, v in value.__dict__ .items() if v is not None} # Getting only attributes which are not None
del without_none['_sa_instance_state']
del without_none['class_id']
return without_none
Loading

0 comments on commit 2662aa7

Please sign in to comment.