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

Commit

Permalink
Feature/share link (#72)
Browse files Browse the repository at this point in the history
* create subfolder for course tests

* added table for share codes

* loading env variables

* added default value to function parameters

* added endpoint for join_codes

* added tests for join_codes

* fixed: linting

* fixed typo:

* fixed import conftest

* fixed merge with singular model naming

* unused file
  • Loading branch information
AronBuzogany authored Mar 13, 2024
1 parent b3e18b0 commit 7dc3923
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 5 deletions.
22 changes: 22 additions & 0 deletions backend/db_construct.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ CREATE TABLE courses (
PRIMARY KEY(course_id)
);

CREATE TABLE course_join_codes (
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,
PRIMARY KEY(join_code)
);

CREATE TABLE course_admins (
course_id INT NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE,
Expand Down Expand Up @@ -55,3 +63,17 @@ CREATE TABLE submissions (
CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE,
CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid)
);

CREATE OR REPLACE FUNCTION remove_expired_codes()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM course_join_codes
WHERE expiry_time < CURRENT_DATE;

RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER remove_expired_codes_trigger
AFTER INSERT OR UPDATE ON course_join_codes
FOR EACH ROW EXECUTE FUNCTION remove_expired_codes();
2 changes: 2 additions & 0 deletions backend/project/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .endpoints.courses.courses_config import courses_bp
from .endpoints.projects.project_endpoint import project_bp
from .endpoints.submissions import submissions_bp
from .endpoints.courses.join_codes.join_codes_config import join_codes_bp

def create_app():
"""
Expand All @@ -23,6 +24,7 @@ def create_app():
app.register_blueprint(courses_bp)
app.register_blueprint(project_bp)
app.register_blueprint(submissions_bp)
app.register_blueprint(join_codes_bp)

return app

Expand Down
2 changes: 2 additions & 0 deletions backend/project/db_in.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""db initialization"""

import os
from dotenv import load_dotenv
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import URL

db = SQLAlchemy()
load_dotenv()

DATABSE_NAME = os.getenv("POSTGRES_DB")
DATABASE_USER = os.getenv("POSTGRES_USER")
Expand Down
6 changes: 3 additions & 3 deletions backend/project/endpoints/courses/courses_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def json_message(message):
return {"message": message}


def get_course_abort_if_not_found(course_id):
def get_course_abort_if_not_found(course_id, url=f"{API_URL}/courses"):
"""
Get a course by its ID.
Expand All @@ -224,11 +224,11 @@ def get_course_abort_if_not_found(course_id):
Course: The course with the given ID.
"""
query = Course.query.filter_by(course_id=course_id)
course = execute_query_abort_if_db_error(query, f"{API_URL}/courses")
course = execute_query_abort_if_db_error(query, url)

if not course:
response = json_message("Course not found")
response["url"] = f"{API_URL}/courses"
response["url"] = url
abort(404, description=response)

return course
48 changes: 48 additions & 0 deletions backend/project/endpoints/courses/join_codes/course_join_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
This file will contain the api endpoints for the /courses/<course_id>/join_codes url
"""

from os import getenv
from urllib.parse import urljoin
from dotenv import load_dotenv

from flask_restful import Resource
from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model
from project.models.course_share_code import CourseShareCode
from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists

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

class CourseJoinCode(Resource):
"""
This class will handle post and delete queries to
the /courses/course_id/join_codes url, only an admin of a course can do this
"""

@check_course_exists
def get(self, course_id, join_code):
"""
This function will return all the join codes of a course
"""

return query_by_id_from_model(
CourseShareCode,
"join_code",
join_code,
urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes")
)

@check_course_exists
def delete(self, course_id, join_code):
"""
Api endpoint for adding new join codes to a course, can only be done by the teacher
"""

return delete_by_id_from_model(
CourseShareCode,
"join_code",
join_code,
urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes")
)
55 changes: 55 additions & 0 deletions backend/project/endpoints/courses/join_codes/course_join_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
This file will contain the api endpoints for the /courses/<course_id>/join_codes url
"""

from os import getenv
from urllib.parse import urljoin
from dotenv import load_dotenv

from flask_restful import Resource
from flask import request
from project.utils.query_agent import query_selected_from_model, insert_into_model
from project.models.course_share_code import CourseShareCode
from project.endpoints.courses.courses_utils import get_course_abort_if_not_found

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

class CourseJoinCodes(Resource):
"""
This class will handle post and delete queries to
the /courses/course_id/join_codes url, only an admin of a course can do this
"""

def get(self, course_id):
"""
This function will return all the join codes of a course
"""

get_course_abort_if_not_found(course_id)

return query_selected_from_model(
CourseShareCode,
urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"),
select_values=["join_code", "expiry_time"],
filters={"course_id": course_id}
)

def post(self, course_id):
"""
Api endpoint for adding new join codes to a course, can only be done by the teacher
"""

get_course_abort_if_not_found(course_id)

data = request.get_json()
data["course_id"] = course_id

return insert_into_model(
CourseShareCode,
data,
urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"),
"join_code",
required_fields=["for_admins"]
)
24 changes: 24 additions & 0 deletions backend/project/endpoints/courses/join_codes/join_codes_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
This file is used to configure the join codes endpoints.
It is used to define the routes for the join codes blueprint and the
corresponding api endpoints.
The join codes blueprint is used to define the routes for the join codes api
endpoints and the join codes api is used to define the routes for the join codes
api endpoints.
"""

from flask import Blueprint
from flask_restful import Api

from project.endpoints.courses.join_codes.course_join_codes import CourseJoinCodes
from project.endpoints.courses.join_codes.course_join_code import CourseJoinCode

join_codes_bp = Blueprint("join_codes", __name__)
join_codes_api = Api(join_codes_bp)

join_codes_bp.add_url_rule("/courses/<int:course_id>/join_codes",
view_func=CourseJoinCodes.as_view('course_join_codes'))

join_codes_bp.add_url_rule("/courses/<int:course_id>/join_codes/<string:join_code>",
view_func=CourseJoinCode.as_view('course_join_code'))
14 changes: 14 additions & 0 deletions backend/project/endpoints/courses/join_codes/join_codes_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
This module contains functions that are used by the join codes resources.
"""

from project.endpoints.courses.courses_utils import get_course_abort_if_not_found

def check_course_exists(func):
"""
Middleware to check if the course exists before handling the request
"""
def wrapper(self, course_id, join_code, *args, **kwargs):
get_course_abort_if_not_found(course_id)
return func(self, course_id, join_code, *args, **kwargs)
return wrapper
22 changes: 22 additions & 0 deletions backend/project/models/course_share_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Course Share Code Model
"""


from dataclasses import dataclass
import uuid
from sqlalchemy import Integer, Column, ForeignKey, Date, Boolean
from sqlalchemy.dialects.postgresql import UUID
from project import db

@dataclass
class CourseShareCode(db.Model):
"""
This class will contain the model for the course share codes
"""
__tablename__ = "course_join_codes"

join_code: int = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False)
expiry_time: str = Column(Date, nullable=True)
for_admins: bool = Column(Boolean, nullable=False)
15 changes: 13 additions & 2 deletions backend/tests/endpoints/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy import create_engine
from project import create_app_with_db
from project.db_in import url, db

from project.models.course import Course
from project.models.user import User
from project.models.project import Project
from project.models.course_relation import CourseStudent,CourseAdmin
from project.models.course_share_code import CourseShareCode
from project import create_app_with_db
from project.db_in import url, db
from project.models.submission import Submission

def users():
Expand Down Expand Up @@ -299,3 +301,12 @@ def course(course_teacher):
"""A course for testing, with the course teacher as the teacher."""
sel2 = Course(name="Sel2", teacher=course_teacher.uid)
return sel2

@pytest.fixture
def share_code_admin(db_with_course):
"""A course with share codes for testing."""
course = db_with_course.query(Course).first()
share_code = CourseShareCode(course_id=course.course_id, for_admins=True)
db_with_course.add(share_code)
db_with_course.commit()
return share_code
53 changes: 53 additions & 0 deletions backend/tests/endpoints/course/share_link_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
This file contains the tests for the share link endpoints of the course resource.
"""

from project.models.course import Course

class TestCourseShareLinks:
"""
Class that will respond to the /courses/course_id/students link
teachers should be able to assign and remove students from courses,
and everyone should be able to list all students assigned to a course
"""

def test_get_share_links(self, db_with_course, client):
"""Test whether the share links are accessible"""
example_course = db_with_course.query(Course).first()
response = client.get(f"courses/{example_course.course_id}/join_codes")
assert response.status_code == 200

def test_post_share_links(self, db_with_course, client):
"""Test whether the share links are accessible to post to"""
example_course = db_with_course.query(Course).first()
response = client.post(
f"courses/{example_course.course_id}/join_codes",
json={"for_admins": True})
assert response.status_code == 201

def test_delete_share_links(self, share_code_admin, client):
"""Test whether the share links are accessible to delete"""
response = client.delete(
f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}")
assert response.status_code == 200

def test_get_share_links_404(self, client):
"""Test whether the share links are accessible"""
response = client.get("courses/0/join_codes")
assert response.status_code == 404

def test_post_share_links_404(self, client):
"""Test whether the share links are accessible to post to"""
response = client.post("courses/0/join_codes", json={"for_admins": True})
assert response.status_code == 404

def test_delete_share_links_404(self, client):
"""Test whether the share links are accessible to delete"""
response = client.delete("courses/0/join_codes/0")
assert response.status_code == 404

def test_for_admins_required(self, db_with_course, client):
"""Test whether the for_admins field is required"""
example_course = db_with_course.query(Course).first()
response = client.post(f"courses/{example_course.course_id}/join_codes", json={})
assert response.status_code == 400

0 comments on commit 7dc3923

Please sign in to comment.