Skip to content

Commit

Permalink
Merge branch 'development' into backend/fix/projectvisiblestudents
Browse files Browse the repository at this point in the history
  • Loading branch information
JibrilExe committed May 23, 2024
2 parents b2596d1 + df9587f commit 3a7e6cc
Show file tree
Hide file tree
Showing 22 changed files with 440 additions and 83 deletions.
20 changes: 18 additions & 2 deletions backend/db_construct.sql
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ CREATE TABLE courses (
);

CREATE TABLE course_join_codes (
join_code UUID DEFAULT gen_random_uuid() NOT NULL,
course_id INT NOT NULL,
join_code UUID DEFAULT gen_random_uuid() NOT NULL,
course_id INT NOT NULL,
expiry_time DATE,
for_admins BOOLEAN NOT NULL,
CONSTRAINT fk_course_join_link FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE,
Expand Down Expand Up @@ -53,12 +53,28 @@ CREATE TABLE projects (
course_id INT NOT NULL,
visible_for_students BOOLEAN NOT NULL,
archived BOOLEAN NOT NULL,
groups_locked BOOLEAN DEFAULT FALSE,
regex_expressions VARCHAR(50)[],
runner runner,
PRIMARY KEY(project_id),
CONSTRAINT fk_course FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE
);

CREATE TABLE groups (
group_id INT GENERATED ALWAYS AS IDENTITY,
project_id INT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
group_size INT NOT NULL,
PRIMARY KEY(project_id, group_id)
);

CREATE TABLE group_students (
uid VARCHAR(255) NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
group_id INT NOT NULL,
project_id INT NOT NULL,
PRIMARY KEY(uid, group_id, project_id),
CONSTRAINT fk_group_reference FOREIGN KEY (group_id, project_id) REFERENCES groups(group_id, project_id) ON DELETE CASCADE
);

CREATE TABLE submissions (
submission_id INT GENERATED ALWAYS AS IDENTITY,
uid VARCHAR(255) NOT NULL,
Expand Down
128 changes: 128 additions & 0 deletions backend/project/endpoints/projects/groups/group_student.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Endpoint for joining and leaving groups in a project"""


from os import getenv
from urllib.parse import urljoin
from dotenv import load_dotenv
from flask import request
from flask_restful import Resource
from sqlalchemy.exc import SQLAlchemyError

from project.utils.query_agent import insert_into_model
from project.models.group import Group
from project.models.project import Project
from project.utils.authentication import authorize_student_submission

from project import db

load_dotenv()
API_URL = getenv("API_HOST")
RESPONSE_URL = urljoin(f"{API_URL}/", "groups")


class GroupStudent(Resource):
"""Api endpoint to allow students to join and leave project groups"""
@authorize_student_submission
def post(self, project_id, group_id, uid=None):
"""
This function will allow students to join project groups if not full
"""
try:
project = db.session.query(Project).filter_by(
project_id=project_id).first()
if project.groups_locked:
return {
"message": "Groups are locked for this project",
"url": RESPONSE_URL
}, 400

group = db.session.query(Group).filter_by(
project_id=project_id, group_id=group_id).first()
if group is None:
return {
"message": "Group does not exist",
"url": RESPONSE_URL
}, 404

joined_groups = db.session.query(GroupStudent).filter_by(
uid=uid, project_id=project_id).all()
if len(joined_groups) > 0:
return {
"message": "Student is already in a group",
"url": RESPONSE_URL
}, 400

joined_students = db.session.query(GroupStudent).filter_by(
group_id=group_id, project_id=project_id).all()
if len(joined_students) >= group.group_size:
return {
"message": "Group is full",
"url": RESPONSE_URL
}, 400

req = request.json
req["project_id"] = project_id
req["group_id"] = group_id
req["uid"] = uid
return insert_into_model(
GroupStudent,
req,
RESPONSE_URL,
"group_id",
required_fields=["project_id", "group_id", "uid"]
)
except SQLAlchemyError:
data = {
"url": urljoin(f"{API_URL}/", "projects")
}
data["message"] = "An error occurred while fetching the projects"
return data, 500


@authorize_student_submission
def delete(self, project_id, group_id, uid=None):
"""
This function will allow students to leave project groups
"""
data = {
"url": urljoin(f"{API_URL}/", "projects")
}
try:
project = db.session.query(Project).filter_by(
project_id=project_id).first()
if project.groups_locked:
return {
"message": "Groups are locked for this project",
"url": RESPONSE_URL
}, 400

group = db.session.query(Group).filter_by(
project_id=project_id, group_id=group_id).first()
if group is None:
return {
"message": "Group does not exist",
"url": RESPONSE_URL
}, 404

if uid is None:
return {
"message": "Failed to verify uid of user",
"url": RESPONSE_URL
}, 400

student_group = db.session.query(GroupStudent).filter_by(
group_id=group_id, project_id=project_id, uid=uid).first()
if student_group is None:
return {
"message": "Student is not in the group",
"url": RESPONSE_URL
}, 404

db.session.delete(student_group)
db.session.commit()
data["message"] = "Student has succesfully left the group"
return data, 200

except SQLAlchemyError:
data["message"] = "An error occurred while fetching the projects"
return data, 500
127 changes: 127 additions & 0 deletions backend/project/endpoints/projects/groups/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Endpoint for creating/deleting groups in a project"""
from os import getenv
from urllib.parse import urljoin
from dotenv import load_dotenv
from flask import request
from flask_restful import Resource
from sqlalchemy.exc import SQLAlchemyError

from project.models.project import Project
from project.models.group import Group
from project.utils.query_agent import query_selected_from_model, insert_into_model
from project.utils.authentication import (
authorize_teacher_or_student_of_project,
authorize_teacher_of_project
)
from project import db

load_dotenv()
API_URL = getenv("API_HOST")
RESPONSE_URL = urljoin(f"{API_URL}/", "groups")


class Groups(Resource):
"""Api endpoint for the /project/project_id/groups link"""

@authorize_teacher_of_project
def patch(self, project_id):
"""
This function will set locked state of project groups,
need to pass locked field in the body
"""
req = request.json
locked = req.get("locked")
if locked is None:
return {
"message": "Bad request: locked field is required",
"url": RESPONSE_URL
}, 400

try:
project = db.session.query(Project).filter_by(
project_id=project_id).first()
if project is None:
return {
"message": "Project does not exist",
"url": RESPONSE_URL
}, 404
project.groups_locked = locked
db.session.commit()

return {
"message": "Groups are locked",
"url": RESPONSE_URL
}, 200
except SQLAlchemyError:
return {
"message": "Database error",
"url": RESPONSE_URL
}, 500

@authorize_teacher_or_student_of_project
def get(self, project_id):
"""
Get function for /project/project_id/groups this will be the main endpoint
to get all groups for a project
"""
return query_selected_from_model(
Group,
RESPONSE_URL,
url_mapper={"group_id": RESPONSE_URL},
filters={"project_id": project_id}
)

@authorize_teacher_of_project
def post(self, project_id):
"""
This function will create a new group for a project
if the body of the post contains a group_size and project_id exists
"""

req = request.json
req["project_id"] = project_id
return insert_into_model(
Group,
req,
RESPONSE_URL,
"group_id",
required_fields=["project_id", "group_size"]
)

@authorize_teacher_of_project
def delete(self, project_id):
"""
This function will delete a group
if group_id is provided and request is from teacher
"""

req = request.json
group_id = req.get("group_id")
if group_id is None:
return {
"message": "Bad request: group_id is required",
"url": RESPONSE_URL
}, 400

try:
project = db.session.query(Project).filter_by(
project_id=project_id).first()
if project is None:
return {
"message": "Project associated with group does not exist",
"url": RESPONSE_URL
}, 404

group = db.session.query(Group).filter_by(
project_id=project_id, group_id=group_id).first()
db.session.delete(group)
db.session.commit()
return {
"message": "Group deleted",
"url": RESPONSE_URL
}, 204
except SQLAlchemyError:
return {
"message": "Database error",
"url": RESPONSE_URL
}, 500
7 changes: 6 additions & 1 deletion backend/project/endpoints/projects/project_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles
from project.endpoints.projects.project_submissions_download import SubmissionDownload
from project.endpoints.projects.project_last_submission import SubmissionPerUser

from project.endpoints.projects.groups.groups import Groups

project_bp = Blueprint('project_endpoint', __name__)

Expand Down Expand Up @@ -38,3 +38,8 @@
'/projects/<int:project_id>/latest-per-user',
view_func=SubmissionPerUser.as_view('latest_per_user')
)

project_bp.add_url_rule(
'/projects/<int:project_id>/groups',
view_func=Groups.as_view('groups')
)
2 changes: 2 additions & 0 deletions backend/project/endpoints/projects/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ def get(self, uid=None):
}
try:
# Get all the courses a user is part of
courses_student = CourseStudent.query.filter_by(uid=uid).\
courses_student = CourseStudent.query.filter_by(uid=uid).\
with_entities(CourseStudent.course_id).all()
courses = CourseAdmin.query.filter_by(uid=uid).\
with_entities(CourseAdmin.course_id).all()
courses += Course.query.filter_by(teacher=uid).with_entities(Course.course_id).all()
courses = [c[0] for c in courses] # Remove the tuple wrapping the course_id
courses_student = [c[0] for c in courses_student]
courses_student = [c[0] for c in courses_student]
# Filter the projects based on the query parameters
filters = dict(request.args)
conditions = []
Expand Down
11 changes: 9 additions & 2 deletions backend/project/endpoints/submissions/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from shutil import rmtree
import zipfile
from flask import request
from flask_restful import Resource
from sqlalchemy import exc, and_
Expand Down Expand Up @@ -104,7 +105,7 @@ def get(self, uid=None) -> dict[str, any]:
return data, 500

@authorize_student_submission
def post(self, uid=None) -> dict[str, any]:
def post(self, uid=None) -> dict[str, any]: # pylint: disable=too-many-locals, too-many-branches, too-many-statements
"""Post a new submission to a project
Returns:
Expand Down Expand Up @@ -174,7 +175,13 @@ def post(self, uid=None) -> dict[str, any]:
input_folder = path.join(submission.submission_path, "submission")
makedirs(input_folder, exist_ok=True)
for file in files:
file.save(path.join(input_folder, file.filename))
file_path = path.join(input_folder, file.filename)
file.save(file_path)
if file.filename.endswith(".zip"):
with zipfile.ZipFile(file_path) as upload_zip:
upload_zip.extractall(input_folder)


except OSError:
rmtree(submission.submission_path)
session.rollback()
Expand Down
17 changes: 17 additions & 0 deletions backend/project/models/group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Group model"""
from dataclasses import dataclass
from sqlalchemy import Integer, Column, ForeignKey
from project import db


@dataclass
class Group(db.Model):
"""
This class will contain the model for the groups
"""
__tablename__ = "groups"

group_id: int = Column(Integer, autoincrement=True, primary_key=True)
project_id: int = Column(Integer, ForeignKey(
"projects.project_id"), autoincrement=False, primary_key=True)
group_size: int = Column(Integer, nullable=False)
13 changes: 13 additions & 0 deletions backend/project/models/group_student.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Model for relation between groups and students"""
from dataclasses import dataclass
from sqlalchemy import Integer, Column, ForeignKey, String
from project.db_in import db

@dataclass
class GroupStudent(db.Model):
"""Model for relation between groups and students"""
__tablename__ = "group_students"

uid: str = Column(String(255), ForeignKey("users.uid"), primary_key=True)
group_id: int = Column(Integer, ForeignKey("groups.group_id"), primary_key=True)
project_id: int = Column(Integer, ForeignKey("groups.project_id"), primary_key=True)
Loading

0 comments on commit 3a7e6cc

Please sign in to comment.