Skip to content
This repository has been archived by the owner on Jan 22, 2025. It is now read-only.

Groups backend #338

Merged
merged 26 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3c386de
temp bad
JibrilExe Apr 29, 2024
27a1aed
db_constr, model and first attempt at endpoint for group
JibrilExe Apr 29, 2024
41277ed
group prim key (group_id,project_id) added delete endpoint to leave g…
JibrilExe Apr 29, 2024
1108688
allow students in max 1 group
JibrilExe Apr 29, 2024
8cb023f
model tests
JibrilExe Apr 29, 2024
4904a19
lint
JibrilExe Apr 29, 2024
329cd6c
group menu frontend
JibrilExe Apr 29, 2024
88b38ce
hm
JibrilExe Apr 30, 2024
9ecef84
working endpoint for create and delete group
JibrilExe Apr 30, 2024
5975542
translations
JibrilExe Apr 30, 2024
5a0c548
Merge branch 'development' into nicetohave/groups
JibrilExe May 7, 2024
44de8a6
begone front
JibrilExe May 7, 2024
c47f02d
front removal
JibrilExe May 7, 2024
38b5282
lintr
JibrilExe May 7, 2024
dad08e7
fixed changes, untested tho
JibrilExe May 12, 2024
6014315
Merge branch 'development' into nicetohave/groups
JibrilExe May 12, 2024
ebec22c
groups locked var should not mess up all older code
JibrilExe May 12, 2024
3ed5561
only student or teacher can get groups ; unlock groups
JibrilExe May 12, 2024
19175d5
linter mad
JibrilExe May 12, 2024
4aa55db
Very mad lintr
JibrilExe May 12, 2024
d8a42c2
vscode linter errors should be more obvi
JibrilExe May 12, 2024
20bdd60
removed some teacher_id = None
JibrilExe May 14, 2024
590e402
removed unused import
JibrilExe May 14, 2024
d623a7f
Merge branch 'development' into nicetohave/groups
JibrilExe May 14, 2024
e145bd2
bad prints
JibrilExe May 16, 2024
54a0795
Merge branch 'development' into nicetohave/groups
JibrilExe May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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):
Vucis marked this conversation as resolved.
Show resolved Hide resolved
"""
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):
Vucis marked this conversation as resolved.
Show resolved Hide resolved
"""
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
129 changes: 129 additions & 0 deletions backend/project/endpoints/projects/groups/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""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.course import Course
from project.models.group import Group
from project.utils.query_agent import query_selected_from_model, insert_into_model
from project.utils.authentication import login_required, 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, teacher_id=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can the teacher unlock groups again?

"""
This function will lock all groups of the project
"""

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 = True
db.session.commit()

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

@login_required
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe make it so only people related to the course can get all groups?

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, teacher_id=None):
"""
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, teacher_id=None):
"""
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
course = db.session.query(Course).filter_by(
course_id=project.course_id).first()
if course is None:
return {
"message": "Course associated with project does not exist",
"url": RESPONSE_URL
}, 404
if course.teacher != teacher_id:
return {
"message": "Unauthorized",
"url": RESPONSE_URL
}, 401
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')
)
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)
1 change: 1 addition & 0 deletions backend/project/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes
course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False)
visible_for_students: bool = Column(Boolean, nullable=False)
archived: bool = Column(Boolean, nullable=False)
groups_locked: bool = Column(Boolean)
runner: Runner = Column(
EnumField(Runner, name="runner"),
nullable=False)
Expand Down
9 changes: 8 additions & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from project.models.project import Project
from project.models.course_relation import CourseStudent,CourseAdmin
from project.models.submission import Submission, SubmissionStatus

from project.models.group import Group


### CLIENT & SESSION ###
Expand Down Expand Up @@ -53,6 +53,8 @@ def session() -> Generator[Session, any, None]:
session.commit()
session.add_all(submissions(session))
session.commit()
session.add(group(session))
session.commit()

yield session
finally:
Expand Down Expand Up @@ -199,3 +201,8 @@ def submissions(session):
submission_status= SubmissionStatus.SUCCESS
)
]

def group(session):
"""Return a group to populate the database"""
project_id = session.query(Project).filter_by(title="B+ Trees").first().project_id
return Group(project_id=project_id, group_size=4)
Loading