From cd2397f4f707abda451548dc790d77b0703d04c1 Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 2 Jul 2024 20:37:00 +0200 Subject: [PATCH 01/25] wip1 --- apps/api/src/db/assignments.py | 133 +++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 apps/api/src/db/assignments.py diff --git a/apps/api/src/db/assignments.py b/apps/api/src/db/assignments.py new file mode 100644 index 00000000..9d8788d5 --- /dev/null +++ b/apps/api/src/db/assignments.py @@ -0,0 +1,133 @@ +from typing import Optional +from openai import BaseModel +from sqlalchemy import JSON, Column, ForeignKey +from sqlmodel import Field, SQLModel +from enum import Enum + + +class AssignmentTypeEnum(str, Enum): + FILE_SUBMISSION = "FILE_SUBMISSION" + QUIZ = "QUIZ" + OTHER = "OTHER" + + +class Assignment(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + assignment_uuid: str + title: str + description: str + due_date: str + + org_id: int = Field( + sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + + creation_date: str + update_date: str + + +class AssignmentTask(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + assignment_task_uuid: str = "" + title: str = "" + description: str = "" + hint: str = "" + assignment_type: AssignmentTypeEnum + contents: dict = Field(default={}, sa_column=Column(JSON)) + max_grade_value: int = ( + 0 # Value is always between 0-100 and is used as the maximum grade for the task + ) + + # Foreign keys + assignment_id: int = Field( + sa_column=Column( + "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") + ) + ) + org_id: int = Field( + sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + creation_date: str + update_date: str + + +class AssignmentTaskSubmission(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + assignment_task_submission_uuid: str = "" + task_submission: dict = Field(default={}, sa_column=Column(JSON)) + grade: int = ( + 0 # Value is always between 0-100 depending on the questions, this is used to calculate the final grade on the AssignmentUser model + ) + task_submission_grade_feedback: str = "" # Feedback given by the teacher + + # Foreign keys + user_id: int = Field( + sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + assignment_task_id: int = Field( + sa_column=Column( + "assignment_task_id", ForeignKey("assignment_task.id", ondelete="CASCADE") + ) + ) + creation_date: str = "" + update_date: str = "" + + +# Note on grading : +# To calculate the final grade : + + + + +class AssignmentUserSubmissionStatus(str, Enum): + PENDING = "PENDING" + SUBMITTED = "SUBMITTED" + GRADED = "GRADED" + LATE = "LATE" + NOT_SUBMITTED = "NOT_SUBMITTED" + + +class AssignmentUserSubmission(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + assignment_user_uuid: str = "" + submission_status: AssignmentUserSubmissionStatus = ( + AssignmentUserSubmissionStatus.PENDING + ) + grading: str = "" + user_id: int = Field( + sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) + ) + assignment_id: int = Field( + sa_column=Column( + "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") + ) + ) + creation_date: str = "" + update_date: str = "" From 47782b57bc224a194eb1e0f34cc38a791f3bf198 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 10 Jul 2024 23:35:32 +0200 Subject: [PATCH 02/25] feat: add Assignments, Tasks, Submissions CRUD --- apps/api/poetry.lock | 61 +- apps/api/src/db/assignments.py | 133 --- apps/api/src/db/{ => courses}/activities.py | 0 apps/api/src/db/courses/assignments.py | 317 ++++++ apps/api/src/db/{ => courses}/blocks.py | 2 +- .../db/{ => courses}/chapter_activities.py | 0 apps/api/src/db/{ => courses}/chapters.py | 8 +- .../src/db/{ => courses}/course_chapters.py | 0 .../src/db/{ => courses}/course_updates.py | 0 apps/api/src/db/{ => courses}/courses.py | 4 +- apps/api/src/db/trails.py | 9 +- apps/api/src/router.py | 6 +- .../courses/{ => activities}/activities.py | 2 +- .../{ => courses/activities}/blocks.py | 2 +- apps/api/src/routers/courses/assignments.py | 310 ++++++ apps/api/src/routers/courses/chapters.py | 2 +- apps/api/src/routers/courses/courses.py | 4 +- apps/api/src/security/rbac/rbac.py | 2 +- apps/api/src/services/ai/ai.py | 4 +- .../block_types/imageBlock/imageBlock.py | 6 +- .../blocks/block_types/pdfBlock/pdfBlock.py | 6 +- .../block_types/videoBlock/videoBlock.py | 6 +- .../services/courses/activities/activities.py | 10 +- .../courses/activities/assignments.py | 997 ++++++++++++++++++ .../src/services/courses/activities/pdf.py | 10 +- .../src/services/courses/activities/utils.py | 4 +- .../src/services/courses/activities/video.py | 10 +- apps/api/src/services/courses/chapters.py | 8 +- apps/api/src/services/courses/collections.py | 2 +- apps/api/src/services/courses/courses.py | 2 +- apps/api/src/services/courses/updates.py | 4 +- apps/api/src/services/trail/trail.py | 6 +- 32 files changed, 1719 insertions(+), 218 deletions(-) delete mode 100644 apps/api/src/db/assignments.py rename apps/api/src/db/{ => courses}/activities.py (100%) create mode 100644 apps/api/src/db/courses/assignments.py rename apps/api/src/db/{ => courses}/blocks.py (96%) rename apps/api/src/db/{ => courses}/chapter_activities.py (100%) rename apps/api/src/db/{ => courses}/chapters.py (90%) rename apps/api/src/db/{ => courses}/course_chapters.py (100%) rename apps/api/src/db/{ => courses}/course_updates.py (100%) rename apps/api/src/db/{ => courses}/courses.py (95%) rename apps/api/src/routers/courses/{ => activities}/activities.py (97%) rename apps/api/src/routers/{ => courses/activities}/blocks.py (98%) create mode 100644 apps/api/src/routers/courses/assignments.py create mode 100644 apps/api/src/services/courses/activities/assignments.py diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index 85625d58..deb41715 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -215,17 +215,17 @@ typecheck = ["mypy"] [[package]] name = "boto3" -version = "1.34.137" +version = "1.34.143" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.137-py3-none-any.whl", hash = "sha256:7cb697d67fd138ceebc6f789919ae370c092a50c6b0ccc4ef483027935502eab"}, - {file = "boto3-1.34.137.tar.gz", hash = "sha256:0b21b84db4619b3711a6f643d465a5a25e81231ee43615c55a20ff6b89c6cc3c"}, + {file = "boto3-1.34.143-py3-none-any.whl", hash = "sha256:0d16832f23e6bd3ae94e35ea8e625529850bfad9baccd426de96ad8f445d8e03"}, + {file = "boto3-1.34.143.tar.gz", hash = "sha256:b590ce80c65149194def43ebf0ea1cf0533945502507837389a8d22e3ecbcf05"}, ] [package.dependencies] -botocore = ">=1.34.137,<1.35.0" +botocore = ">=1.34.143,<1.35.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -234,13 +234,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.137" +version = "1.34.143" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.137-py3-none-any.whl", hash = "sha256:a980fa4adec4bfa23fff70a3512622e9412c69c791898a52cafc2458b0be6040"}, - {file = "botocore-1.34.137.tar.gz", hash = "sha256:e29c8e9bfda0b20a1997792968e85868bfce42fefad9730f633a81adcff3f2ef"}, + {file = "botocore-1.34.143-py3-none-any.whl", hash = "sha256:094aea179e8aaa1bc957ad49cc27d93b189dd3a1f3075d8b0ca7c445a2a88430"}, + {file = "botocore-1.34.143.tar.gz", hash = "sha256:059f032ec05733a836e04e869c5a15534420102f93116f3bc9a5b759b0651caf"}, ] [package.dependencies] @@ -844,13 +844,13 @@ tqdm = ["tqdm"] [[package]] name = "google-auth" -version = "2.31.0" +version = "2.32.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" files = [ - {file = "google-auth-2.31.0.tar.gz", hash = "sha256:87805c36970047247c8afe614d4e3af8eceafc1ebba0c679fe75ddd1d575e871"}, - {file = "google_auth-2.31.0-py2.py3-none-any.whl", hash = "sha256:042c4702efa9f7d3c48d3a69341c209381b125faa6dbf3ebe56bc7e40ae05c23"}, + {file = "google_auth-2.32.0-py2.py3-none-any.whl", hash = "sha256:53326ea2ebec768070a94bee4e1b9194c9646ea0c2bd72422785bd0f9abfad7b"}, + {file = "google_auth-2.32.0.tar.gz", hash = "sha256:49315be72c55a6a37d62819e3573f6b416aca00721f7e3e31a008d928bf64022"}, ] [package.dependencies] @@ -1877,13 +1877,13 @@ sympy = "*" [[package]] name = "openai" -version = "1.35.8" +version = "1.35.13" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.35.8-py3-none-any.whl", hash = "sha256:69d5b0f47f0c806d5da83fb0f84c147661395226d7f79acc78aa1d9b8c635887"}, - {file = "openai-1.35.8.tar.gz", hash = "sha256:3f6101888bb516647edade74c503f2b937b8bab73408e799d58f2aba68bbe51c"}, + {file = "openai-1.35.13-py3-none-any.whl", hash = "sha256:36ec3e93e0d1f243f69be85c89b9221a471c3e450dfd9df16c9829e3cdf63e60"}, + {file = "openai-1.35.13.tar.gz", hash = "sha256:c684f3945608baf7d2dcc0ef3ee6f3e27e4c66f21076df0b47be45d57e6ae6e4"}, ] [package.dependencies] @@ -2732,13 +2732,13 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"] [[package]] name = "resend" -version = "2.1.0" +version = "2.2.0" description = "Resend Python SDK" optional = false python-versions = ">=3.7" files = [ - {file = "resend-2.1.0-py2.py3-none-any.whl", hash = "sha256:7f2a221983fab74a09f669c0c14a75daf547ffa4b4930141626d9cca55bca767"}, - {file = "resend-2.1.0.tar.gz", hash = "sha256:92dc8e035c2ce8cf8210c1c322e86b0a4f509e0c82a80932d3323cd2f3a43d2d"}, + {file = "resend-2.2.0-py2.py3-none-any.whl", hash = "sha256:be420762885de25c816497f09207da1cd54d253c44d9f81f441367893a42d099"}, + {file = "resend-2.2.0.tar.gz", hash = "sha256:f44976e4a37bb66445280bd8ef201eaac536b07bbe7c4da8f1717f4fcc63da7e"}, ] [package.dependencies] @@ -2796,13 +2796,13 @@ crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "2.7.1" +version = "2.9.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.7.1-py2.py3-none-any.whl", hash = "sha256:ef1b3d54eb715825657cd4bb3cb42bb4dc85087bac14c56b0fd8c21abd968c9a"}, - {file = "sentry_sdk-2.7.1.tar.gz", hash = "sha256:25006c7e68b75aaa5e6b9c6a420ece22e8d7daec4b7a906ffd3a8607b67c037b"}, + {file = "sentry_sdk-2.9.0-py2.py3-none-any.whl", hash = "sha256:0bea5fa8b564cc0d09f2e6f55893e8f70286048b0ffb3a341d5b695d1af0e6ee"}, + {file = "sentry_sdk-2.9.0.tar.gz", hash = "sha256:4c85bad74df9767976afb3eeddc33e0e153300e887d637775a753a35ef99bee6"}, ] [package.dependencies] @@ -2847,13 +2847,13 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "70.2.0" +version = "70.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-70.2.0-py3-none-any.whl", hash = "sha256:b8b8060bb426838fbe942479c90296ce976249451118ef566a5a0b7d8b78fb05"}, - {file = "setuptools-70.2.0.tar.gz", hash = "sha256:bd63e505105011b25c3c11f753f7e3b8465ea739efddaccef8f0efac2137bac1"}, + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, ] [package.extras] @@ -3014,27 +3014,30 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 [[package]] name = "sympy" -version = "1.12.1" +version = "1.13.0" description = "Computer algebra system (CAS) in Python" optional = false python-versions = ">=3.8" files = [ - {file = "sympy-1.12.1-py3-none-any.whl", hash = "sha256:9b2cbc7f1a640289430e13d2a56f02f867a1da0190f2f99d8968c2f74da0e515"}, - {file = "sympy-1.12.1.tar.gz", hash = "sha256:2877b03f998cd8c08f07cd0de5b767119cd3ef40d09f41c30d722f6686b0fb88"}, + {file = "sympy-1.13.0-py3-none-any.whl", hash = "sha256:6b0b32a4673fb91bd3cac3b55406c8e01d53ae22780be467301cc452f6680c92"}, + {file = "sympy-1.13.0.tar.gz", hash = "sha256:3b6af8f4d008b9a1a6a4268b335b984b23835f26d1d60b0526ebc71d48a25f57"}, ] [package.dependencies] -mpmath = ">=1.1.0,<1.4.0" +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] [[package]] name = "tenacity" -version = "8.4.2" +version = "8.5.0" description = "Retry code until it succeeds" optional = false python-versions = ">=3.8" files = [ - {file = "tenacity-8.4.2-py3-none-any.whl", hash = "sha256:9e6f7cf7da729125c7437222f8a522279751cdfbe6b67bfe64f75d3a348661b2"}, - {file = "tenacity-8.4.2.tar.gz", hash = "sha256:cd80a53a79336edba8489e767f729e4f391c896956b57140b5d7511a64bbd3ef"}, + {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, + {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, ] [package.extras] diff --git a/apps/api/src/db/assignments.py b/apps/api/src/db/assignments.py deleted file mode 100644 index 9d8788d5..00000000 --- a/apps/api/src/db/assignments.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import Optional -from openai import BaseModel -from sqlalchemy import JSON, Column, ForeignKey -from sqlmodel import Field, SQLModel -from enum import Enum - - -class AssignmentTypeEnum(str, Enum): - FILE_SUBMISSION = "FILE_SUBMISSION" - QUIZ = "QUIZ" - OTHER = "OTHER" - - -class Assignment(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - assignment_uuid: str - title: str - description: str - due_date: str - - org_id: int = Field( - sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) - ) - course_id: int = Field( - sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) - ) - chapter_id: int = Field( - sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) - ) - activity_id: int = Field( - sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) - ) - - creation_date: str - update_date: str - - -class AssignmentTask(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - assignment_task_uuid: str = "" - title: str = "" - description: str = "" - hint: str = "" - assignment_type: AssignmentTypeEnum - contents: dict = Field(default={}, sa_column=Column(JSON)) - max_grade_value: int = ( - 0 # Value is always between 0-100 and is used as the maximum grade for the task - ) - - # Foreign keys - assignment_id: int = Field( - sa_column=Column( - "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") - ) - ) - org_id: int = Field( - sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) - ) - course_id: int = Field( - sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) - ) - chapter_id: int = Field( - sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) - ) - activity_id: int = Field( - sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) - ) - creation_date: str - update_date: str - - -class AssignmentTaskSubmission(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - assignment_task_submission_uuid: str = "" - task_submission: dict = Field(default={}, sa_column=Column(JSON)) - grade: int = ( - 0 # Value is always between 0-100 depending on the questions, this is used to calculate the final grade on the AssignmentUser model - ) - task_submission_grade_feedback: str = "" # Feedback given by the teacher - - # Foreign keys - user_id: int = Field( - sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) - ) - activity_id: int = Field( - sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) - ) - course_id: int = Field( - sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) - ) - chapter_id: int = Field( - sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) - ) - assignment_task_id: int = Field( - sa_column=Column( - "assignment_task_id", ForeignKey("assignment_task.id", ondelete="CASCADE") - ) - ) - creation_date: str = "" - update_date: str = "" - - -# Note on grading : -# To calculate the final grade : - - - - -class AssignmentUserSubmissionStatus(str, Enum): - PENDING = "PENDING" - SUBMITTED = "SUBMITTED" - GRADED = "GRADED" - LATE = "LATE" - NOT_SUBMITTED = "NOT_SUBMITTED" - - -class AssignmentUserSubmission(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - assignment_user_uuid: str = "" - submission_status: AssignmentUserSubmissionStatus = ( - AssignmentUserSubmissionStatus.PENDING - ) - grading: str = "" - user_id: int = Field( - sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) - ) - assignment_id: int = Field( - sa_column=Column( - "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") - ) - ) - creation_date: str = "" - update_date: str = "" diff --git a/apps/api/src/db/activities.py b/apps/api/src/db/courses/activities.py similarity index 100% rename from apps/api/src/db/activities.py rename to apps/api/src/db/courses/activities.py diff --git a/apps/api/src/db/courses/assignments.py b/apps/api/src/db/courses/assignments.py new file mode 100644 index 00000000..78632aa2 --- /dev/null +++ b/apps/api/src/db/courses/assignments.py @@ -0,0 +1,317 @@ +from typing import Optional, Dict +from sqlalchemy import JSON, Column, ForeignKey +from sqlmodel import Field, SQLModel +from enum import Enum + + + +## Assignment ## +class GradingTypeEnum(str, Enum): + ALPHABET = "ALPHABET" + NUMERIC = "NUMERIC" + PERCENTAGE = "PERCENTAGE" + + +class AssignmentBase(SQLModel): + """Represents the common fields for an assignment.""" + + title: str + description: str + due_date: str + published: Optional[bool] = False + grading_type: GradingTypeEnum + + org_id: int + course_id: int + chapter_id: int + activity_id: int + + +class AssignmentCreate(AssignmentBase): + """Model for creating a new assignment.""" + + pass # Inherits all fields from AssignmentBase + + +class AssignmentRead(AssignmentBase): + """Model for reading an assignment.""" + + id: int + assignment_uuid: str + creation_date: Optional[str] + update_date: Optional[str] + + +class AssignmentUpdate(SQLModel): + """Model for updating an assignment.""" + + title: Optional[str] + description: Optional[str] + due_date: Optional[str] + published: Optional[bool] + grading_type: Optional[GradingTypeEnum] + org_id: Optional[int] + course_id: Optional[int] + chapter_id: Optional[int] + activity_id: Optional[int] + update_date: Optional[str] + + +class Assignment(AssignmentBase, table=True): + """Represents an assignment with relevant details and foreign keys.""" + + id: Optional[int] = Field(default=None, primary_key=True) + creation_date: Optional[str] + update_date: Optional[str] + assignment_uuid: str + + org_id: int = Field( + sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + + +## Assignment ## + +## AssignmentTask ## + + +class AssignmentTaskTypeEnum(str, Enum): + FILE_SUBMISSION = "FILE_SUBMISSION" + QUIZ = "QUIZ" + FORM = "FORM" # soon to be implemented + OTHER = "OTHER" + + +class AssignmentTaskBase(SQLModel): + """Represents the common fields for an assignment task.""" + + title: str + description: str + hint: str + assignment_type: AssignmentTaskTypeEnum + contents: Dict = Field(default={}, sa_column=Column(JSON)) + max_grade_value: int = 0 # Value is always between 0-100 + + assignment_id: int + org_id: int + course_id: int + chapter_id: int + activity_id: int + + +class AssignmentTaskCreate(AssignmentTaskBase ): + """Model for creating a new assignment task.""" + + pass # Inherits all fields from AssignmentTaskBase + + +class AssignmentTaskRead(AssignmentTaskBase): + """Model for reading an assignment task.""" + + id: int + assignment_task_uuid: str + + +class AssignmentTaskUpdate(SQLModel): + """Model for updating an assignment task.""" + + title: Optional[str] + description: Optional[str] + hint: Optional[str] + assignment_type: Optional[AssignmentTaskTypeEnum] + contents: Optional[Dict] = Field(default={}, sa_column=Column(JSON)) + max_grade_value: Optional[int] + assignment_id: Optional[int] + org_id: Optional[int] + course_id: Optional[int] + chapter_id: Optional[int] + activity_id: Optional[int] + + +class AssignmentTask(AssignmentTaskBase, table=True): + """Represents a task within an assignment with various attributes and foreign keys.""" + + id: Optional[int] = Field(default=None, primary_key=True) + + assignment_task_uuid: str + creation_date: str + update_date: str + + assignment_id: int = Field( + sa_column=Column( + "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") + ) + ) + org_id: int = Field( + sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + + +## AssignmentTask ## + + +## AssignmentTaskSubmission ## + + +class AssignmentTaskSubmissionBase(SQLModel): + """Represents the common fields for an assignment task submission.""" + + task_submission: Dict = Field(default={}, sa_column=Column(JSON)) + grade: int = 0 # Value is always between 0-100 + task_submission_grade_feedback: str + assignment_type: AssignmentTaskTypeEnum + + user_id: int + activity_id: int + course_id: int + chapter_id: int + assignment_task_id: int + + +class AssignmentTaskSubmissionCreate(AssignmentTaskSubmissionBase): + """Model for creating a new assignment task submission.""" + + pass # Inherits all fields from AssignmentTaskSubmissionBase + + +class AssignmentTaskSubmissionRead(AssignmentTaskSubmissionBase): + """Model for reading an assignment task submission.""" + + id: int + creation_date: str + update_date: str + + +class AssignmentTaskSubmissionUpdate(SQLModel): + """Model for updating an assignment task submission.""" + + assignment_task_submission_uuid: Optional[str] + task_submission: Optional[Dict] = Field(default={}, sa_column=Column(JSON)) + grade: Optional[int] + task_submission_grade_feedback: Optional[str] + assignment_type: Optional[AssignmentTaskTypeEnum] + user_id: Optional[int] + activity_id: Optional[int] + course_id: Optional[int] + chapter_id: Optional[int] + assignment_task_id: Optional[int] + + +class AssignmentTaskSubmission(AssignmentTaskSubmissionBase, table=True): + """Represents a submission for a specific assignment task with grade and feedback.""" + + id: Optional[int] = Field(default=None, primary_key=True) + assignment_task_submission_uuid: str + task_submission: Dict = Field(default={}, sa_column=Column(JSON)) + grade: int = 0 # Value is always between 0-100 + task_submission_grade_feedback: str + assignment_type: AssignmentTaskTypeEnum + + user_id: int = Field( + sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) + ) + activity_id: int = Field( + sa_column=Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE")) + ) + assignment_task_id: int = Field( + sa_column=Column( + "assignment_task_id", ForeignKey("assignmenttask.id", ondelete="CASCADE") + ) + ) + + creation_date: str + update_date: str + +## AssignmentTaskSubmission ## + +## AssignmentUserSubmission ## + +class AssignmentUserSubmissionStatus(str, Enum): + PENDING = "PENDING" + SUBMITTED = "SUBMITTED" + GRADED = "GRADED" + LATE = "LATE" + NOT_SUBMITTED = "NOT_SUBMITTED" + + +class AssignmentUserSubmissionBase(SQLModel): + """Represents the submission status of an assignment for a user.""" + + + submission_status: AssignmentUserSubmissionStatus = ( + AssignmentUserSubmissionStatus.PENDING + ) + grade: str + user_id: int = Field( + sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) + ) + assignment_id: int = Field( + sa_column=Column( + "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") + ) + ) + +class AssignmentUserSubmissionCreate(AssignmentUserSubmissionBase): + """Model for creating a new assignment user submission.""" + + pass # Inherits all fields from AssignmentUserSubmissionBase + +class AssignmentUserSubmissionRead(AssignmentUserSubmissionBase): + """Model for reading an assignment user submission.""" + + id: int + creation_date: str + update_date: str + +class AssignmentUserSubmissionUpdate(SQLModel): + """Model for updating an assignment user submission.""" + + submission_status: Optional[AssignmentUserSubmissionStatus] + grade: Optional[str] + user_id: Optional[int] + assignment_id: Optional[int] + +class AssignmentUserSubmission(AssignmentUserSubmissionBase, table=True): + """Represents the submission status of an assignment for a user.""" + + id: Optional[int] = Field(default=None, primary_key=True) + creation_date: str + update_date: str + + submission_status: AssignmentUserSubmissionStatus = ( + AssignmentUserSubmissionStatus.PENDING + ) + grade: str + user_id: int = Field( + sa_column=Column("user_id", ForeignKey("user.id", ondelete="CASCADE")) + ) + assignment_id: int = Field( + sa_column=Column( + "assignment_id", ForeignKey("assignment.id", ondelete="CASCADE") + ) + ) + diff --git a/apps/api/src/db/blocks.py b/apps/api/src/db/courses/blocks.py similarity index 96% rename from apps/api/src/db/blocks.py rename to apps/api/src/db/courses/blocks.py index 453429f7..c8723cf9 100644 --- a/apps/api/src/db/blocks.py +++ b/apps/api/src/db/courses/blocks.py @@ -35,7 +35,7 @@ class BlockCreate(BlockBase): class BlockRead(BlockBase): - id: int + id: int = Field(default=None, primary_key=True) org_id: int = Field(default=None, foreign_key="organization.id") course_id: int = Field(default=None, foreign_key="course.id") chapter_id: int = Field(default=None, foreign_key="chapter.id") diff --git a/apps/api/src/db/chapter_activities.py b/apps/api/src/db/courses/chapter_activities.py similarity index 100% rename from apps/api/src/db/chapter_activities.py rename to apps/api/src/db/courses/chapter_activities.py diff --git a/apps/api/src/db/chapters.py b/apps/api/src/db/courses/chapters.py similarity index 90% rename from apps/api/src/db/chapters.py rename to apps/api/src/db/courses/chapters.py index 4e94dc62..d5c30b3d 100644 --- a/apps/api/src/db/chapters.py +++ b/apps/api/src/db/courses/chapters.py @@ -2,7 +2,7 @@ from typing import Any, List, Optional from pydantic import BaseModel from sqlalchemy import Column, ForeignKey from sqlmodel import Field, SQLModel -from src.db.activities import ActivityRead +from src.db.courses.activities import ActivityRead class ChapterBase(SQLModel): @@ -33,10 +33,10 @@ class ChapterCreate(ChapterBase): class ChapterUpdate(ChapterBase): name: Optional[str] - description: Optional[str] - thumbnail_image: Optional[str] + description: Optional[str] = "" + thumbnail_image: Optional[str] = "" course_id: Optional[int] - org_id: Optional[int] + org_id: Optional[int] # type: ignore class ChapterRead(ChapterBase): diff --git a/apps/api/src/db/course_chapters.py b/apps/api/src/db/courses/course_chapters.py similarity index 100% rename from apps/api/src/db/course_chapters.py rename to apps/api/src/db/courses/course_chapters.py diff --git a/apps/api/src/db/course_updates.py b/apps/api/src/db/courses/course_updates.py similarity index 100% rename from apps/api/src/db/course_updates.py rename to apps/api/src/db/courses/course_updates.py diff --git a/apps/api/src/db/courses.py b/apps/api/src/db/courses/courses.py similarity index 95% rename from apps/api/src/db/courses.py rename to apps/api/src/db/courses/courses.py index 31586d25..bbc97af6 100644 --- a/apps/api/src/db/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -3,7 +3,7 @@ from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, SQLModel from src.db.users import UserRead from src.db.trails import TrailRead -from src.db.chapters import ChapterRead +from src.db.courses.chapters import ChapterRead class CourseBase(SQLModel): @@ -21,7 +21,7 @@ class Course(CourseBase, table=True): org_id: int = Field( sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) ) - course_uuid: str = "" + course_uuid: str = "" creation_date: str = "" update_date: str = "" diff --git a/apps/api/src/db/trails.py b/apps/api/src/db/trails.py index da6238c0..afce3b7d 100644 --- a/apps/api/src/db/trails.py +++ b/apps/api/src/db/trails.py @@ -6,8 +6,12 @@ from src.db.trail_runs import TrailRunRead class TrailBase(SQLModel): - org_id: int = Field(default=None, foreign_key="organization.id") - user_id: int = Field(default=None, foreign_key="user.id") + org_id: int = Field( + sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) + ) + user_id: int = Field( + sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + ) class Trail(TrailBase, table=True): @@ -20,6 +24,7 @@ class Trail(TrailBase, table=True): class TrailCreate(TrailBase): pass + # TODO: This is a hacky way to get around the list[TrailRun] issue, find a better way to do this class TrailRead(BaseModel): id: Optional[int] = Field(default=None, primary_key=True) diff --git a/apps/api/src/router.py b/apps/api/src/router.py index fd171096..cc4d908c 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends from src.routers import usergroups -from src.routers import blocks, dev, trail, users, auth, orgs, roles +from src.routers import dev, trail, users, auth, orgs, roles from src.routers.ai import ai -from src.routers.courses import chapters, collections, courses, activities +from src.routers.courses import chapters, collections, courses, assignments +from src.routers.courses.activities import activities, blocks from src.routers.install import install from src.services.dev.dev import isDevModeEnabledOrRaise from src.services.install.install import isInstallModeEnabled @@ -19,6 +20,7 @@ v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) v1_router.include_router(roles.router, prefix="/roles", tags=["roles"]) v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"]) v1_router.include_router(courses.router, prefix="/courses", tags=["courses"]) +v1_router.include_router(assignments.router, prefix="/assignments", tags=["assignments"]) v1_router.include_router(chapters.router, prefix="/chapters", tags=["chapters"]) v1_router.include_router(activities.router, prefix="/activities", tags=["activities"]) v1_router.include_router(collections.router, prefix="/collections", tags=["collections"]) diff --git a/apps/api/src/routers/courses/activities.py b/apps/api/src/routers/courses/activities/activities.py similarity index 97% rename from apps/api/src/routers/courses/activities.py rename to apps/api/src/routers/courses/activities/activities.py index 8afad228..d7c69035 100644 --- a/apps/api/src/routers/courses/activities.py +++ b/apps/api/src/routers/courses/activities/activities.py @@ -1,6 +1,6 @@ from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request -from src.db.activities import ActivityCreate, ActivityRead, ActivityUpdate +from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate from src.db.users import PublicUser from src.core.events.database import get_db_session from src.services.courses.activities.activities import ( diff --git a/apps/api/src/routers/blocks.py b/apps/api/src/routers/courses/activities/blocks.py similarity index 98% rename from apps/api/src/routers/blocks.py rename to apps/api/src/routers/courses/activities/blocks.py index 19f4b03b..fea2f20d 100644 --- a/apps/api/src/routers/blocks.py +++ b/apps/api/src/routers/courses/activities/blocks.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, UploadFile, Form, Request -from src.db.blocks import BlockRead +from src.db.courses.blocks import BlockRead from src.core.events.database import get_db_session from src.security.auth import get_current_user from src.services.blocks.block_types.imageBlock.imageBlock import ( diff --git a/apps/api/src/routers/courses/assignments.py b/apps/api/src/routers/courses/assignments.py new file mode 100644 index 00000000..6bcd334e --- /dev/null +++ b/apps/api/src/routers/courses/assignments.py @@ -0,0 +1,310 @@ +from fastapi import APIRouter, Depends, Request +from src.db.courses.assignments import ( + AssignmentCreate, + AssignmentRead, + AssignmentTaskCreate, + AssignmentTaskSubmissionCreate, + AssignmentTaskUpdate, + AssignmentUpdate, + AssignmentUserSubmissionCreate, +) +from src.db.users import PublicUser +from src.core.events.database import get_db_session +from src.security.auth import get_current_user +from src.services.courses.activities.assignments import ( + create_assignment, + create_assignment_submission, + create_assignment_task, + create_assignment_task_submission, + delete_assignment, + delete_assignment_submission, + delete_assignment_task, + delete_assignment_task_submission, + read_assignment, + read_assignment_submissions, + read_assignment_task_submissions, + read_assignment_tasks, + read_user_assignment_submissions, + read_user_assignment_task_submissions, + update_assignment, + update_assignment_submission, + update_assignment_task, +) + +router = APIRouter() + +## ASSIGNMENTS ## + + +@router.post("/") +async def api_create_assignments( + request: Request, + assignment_object: AssignmentCreate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> AssignmentRead: + """ + Create new activity + """ + return await create_assignment(request, assignment_object, current_user, db_session) + + +@router.get("/{assignment_uuid}") +async def api_read_assignment( + request: Request, + assignment_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> AssignmentRead: + """ + Read an assignment + """ + return await read_assignment(request, assignment_uuid, current_user, db_session) + + +@router.put("/{assignment_uuid}") +async def api_update_assignment( + request: Request, + assignment_uuid: str, + assignment_object: AssignmentUpdate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> AssignmentRead: + """ + Update an assignment + """ + return await update_assignment( + request, assignment_uuid, assignment_object, current_user, db_session + ) + + +@router.delete("/{assignment_uuid}") +async def api_delete_assignment( + request: Request, + assignment_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Delete an assignment + """ + return await delete_assignment(request, assignment_uuid, current_user, db_session) + + +## ASSIGNMENTS Tasks ## + + +@router.post("/{assignment_uuid}/tasks") +async def api_create_assignment_tasks( + request: Request, + assignment_uuid: str, + assignment_task_object: AssignmentTaskCreate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Create new tasks for an assignment + """ + return await create_assignment_task( + request, assignment_uuid, assignment_task_object, current_user, db_session + ) + + +@router.get("/{assignment_uuid}/tasks") +async def api_read_assignment_tasks( + request: Request, + assignment_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read tasks for an assignment + """ + return await read_assignment_tasks( + request, assignment_uuid, current_user, db_session + ) + + +@router.put("/{assignment_uuid}/tasks/{task_uuid}") +async def api_update_assignment_tasks( + request: Request, + assignment_task_uuid: str, + assignment_task_object: AssignmentTaskUpdate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Update tasks for an assignment + """ + return await update_assignment_task( + request, assignment_task_uuid, assignment_task_object, current_user, db_session + ) + + +@router.delete("/{assignment_uuid}/tasks/{task_uuid}") +async def api_delete_assignment_tasks( + request: Request, + assignment_task_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Delete tasks for an assignment + """ + return await delete_assignment_task( + request, assignment_task_uuid, current_user, db_session + ) + + +## ASSIGNMENTS Tasks Submissions ## + + +@router.post("/{assignment_uuid}/tasks/{assignment_task_uuid}/submissions") +async def api_create_assignment_task_submissions( + request: Request, + assignment_task_submission_object: AssignmentTaskSubmissionCreate, + assignment_task_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Create new task submissions for an assignment + """ + return await create_assignment_task_submission( + request, + assignment_task_uuid, + assignment_task_submission_object, + current_user, + db_session, + ) + + +@router.get("/{assignment_uuid}/tasks/{assignment_task_uuid}/submissions/{user_id}") +async def api_read_user_assignment_task_submissions( + request: Request, + assignment_task_uuid: str, + user_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read task submissions for an assignment from a user + """ + return await read_user_assignment_task_submissions( + request, assignment_task_uuid, user_id, current_user, db_session + ) + + +@router.get("/{assignment_uuid}/tasks/{assignment_task_uuid}/submissions") +async def api_read_assignment_task_submissions( + request: Request, + assignment_task_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read task submissions for an assignment from a user + """ + return await read_assignment_task_submissions( + request, assignment_task_uuid, current_user, db_session + ) + + +@router.delete( + "/{assignment_uuid}/tasks/{assignment_task_uuid}/submissions/{assignment_task_submission_uuid}" +) +async def api_delete_assignment_task_submissions( + request: Request, + assignment_task_submission_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Delete task submissions for an assignment from a user + """ + return await delete_assignment_task_submission( + request, assignment_task_submission_uuid, current_user, db_session + ) + + +## ASSIGNMENTS Submissions ## + + +@router.post("/{assignment_uuid}/submissions") +async def api_create_assignment_submissions( + request: Request, + assignment_uuid: str, + assignment_submission: AssignmentUserSubmissionCreate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Create new submissions for an assignment + """ + return await create_assignment_submission( + request, assignment_uuid, assignment_submission, current_user, db_session + ) + + +@router.get("/{assignment_uuid}/submissions") +async def api_read_assignment_submissions( + request: Request, + assignment_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read submissions for an assignment + """ + return await read_assignment_submissions( + request, assignment_uuid, current_user, db_session + ) + + +@router.get("/{assignment_uuid}/submissions/{user_id}") +async def api_read_user_assignment_submissions( + request: Request, + assignment_uuid: str, + user_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read submissions for an assignment from a user + """ + return await read_user_assignment_submissions( + request, assignment_uuid, user_id, current_user, db_session + ) + + +@router.put("/{assignment_uuid}/submissions/{user_id}") +async def api_update_user_assignment_submissions( + request: Request, + assignment_uuid: str, + user_id: str, + assignment_submission: AssignmentUserSubmissionCreate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Update submissions for an assignment from a user + """ + return await update_assignment_submission( + request, user_id, assignment_submission, current_user, db_session + ) + + +@router.delete("/{assignment_uuid}/submissions/{user_id}") +async def api_delete_user_assignment_submissions( + request: Request, + assignment_id: str, + user_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Delete submissions for an assignment from a user + """ + return await delete_assignment_submission( + request, assignment_id, user_id, current_user, db_session + ) diff --git a/apps/api/src/routers/courses/chapters.py b/apps/api/src/routers/courses/chapters.py index f6cf42db..0d33e6ec 100644 --- a/apps/api/src/routers/courses/chapters.py +++ b/apps/api/src/routers/courses/chapters.py @@ -1,7 +1,7 @@ from typing import List from fastapi import APIRouter, Depends, Request from src.core.events.database import get_db_session -from src.db.chapters import ( +from src.db.courses.chapters import ( ChapterCreate, ChapterRead, ChapterUpdate, diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 38057069..e81bf9ae 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -2,13 +2,13 @@ from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request from sqlmodel import Session from src.core.events.database import get_db_session -from src.db.course_updates import ( +from src.db.courses.course_updates import ( CourseUpdateCreate, CourseUpdateRead, CourseUpdateUpdate, ) from src.db.users import PublicUser -from src.db.courses import ( +from src.db.courses.courses import ( CourseCreate, CourseRead, CourseUpdate, diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index d7055a79..04b64ca1 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -3,7 +3,7 @@ from fastapi import HTTPException, status, Request from sqlalchemy import null from sqlmodel import Session, select from src.db.collections import Collection -from src.db.courses import Course +from src.db.courses.courses import Course from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.roles import Role from src.db.user_organizations import UserOrganization diff --git a/apps/api/src/services/ai/ai.py b/apps/api/src/services/ai/ai.py index 8afc7f51..17516d76 100644 --- a/apps/api/src/services/ai/ai.py +++ b/apps/api/src/services/ai/ai.py @@ -3,10 +3,10 @@ from sqlmodel import Session, select from src.db.organization_config import OrganizationConfig from src.db.organizations import Organization from src.services.ai.utils import check_limits_and_config, count_ai_ask -from src.db.courses import Course, CourseRead +from src.db.courses.courses import Course, CourseRead from src.core.events.database import get_db_session from src.db.users import PublicUser -from src.db.activities import Activity, ActivityRead +from src.db.courses.activities import Activity, ActivityRead from src.security.auth import get_current_user from src.services.ai.base import ask_ai, get_chat_session_history diff --git a/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py b/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py index 63fabcc7..3b4dc539 100644 --- a/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py +++ b/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py @@ -3,9 +3,9 @@ from uuid import uuid4 from src.db.organizations import Organization from fastapi import HTTPException, status, UploadFile, Request from sqlmodel import Session, select -from src.db.activities import Activity -from src.db.blocks import Block, BlockRead, BlockTypeEnum -from src.db.courses import Course +from src.db.courses.activities import Activity +from src.db.courses.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses.courses import Course from src.services.blocks.utils.upload_files import upload_file_and_return_file_object from src.services.users.users import PublicUser diff --git a/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py b/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py index 4d69cc89..46f4d004 100644 --- a/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py +++ b/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py @@ -3,9 +3,9 @@ from uuid import uuid4 from src.db.organizations import Organization from fastapi import HTTPException, status, UploadFile, Request from sqlmodel import Session, select -from src.db.activities import Activity -from src.db.blocks import Block, BlockRead, BlockTypeEnum -from src.db.courses import Course +from src.db.courses.activities import Activity +from src.db.courses.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses.courses import Course from src.services.blocks.utils.upload_files import upload_file_and_return_file_object from src.services.users.users import PublicUser diff --git a/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py b/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py index 2e05ec01..481b5162 100644 --- a/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py +++ b/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py @@ -3,9 +3,9 @@ from uuid import uuid4 from src.db.organizations import Organization from fastapi import HTTPException, status, UploadFile, Request from sqlmodel import Session, select -from src.db.activities import Activity -from src.db.blocks import Block, BlockRead, BlockTypeEnum -from src.db.courses import Course +from src.db.courses.activities import Activity +from src.db.courses.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses.courses import Course from src.services.blocks.utils.upload_files import upload_file_and_return_file_object from src.services.users.users import PublicUser diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index e976f8c3..f3a93d2c 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -1,14 +1,14 @@ from typing import Literal from sqlmodel import Session, select -from src.db.courses import Course -from src.db.chapters import Chapter +from src.db.courses.courses import Course +from src.db.courses.chapters import Chapter from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) -from src.db.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate -from src.db.chapter_activities import ChapterActivity +from src.db.courses.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate +from src.db.courses.chapter_activities import ChapterActivity from src.db.users import AnonymousUser, PublicUser from fastapi import HTTPException, Request from uuid import uuid4 @@ -58,7 +58,7 @@ async def create_activity( statement = ( select(ChapterActivity) .where(ChapterActivity.chapter_id == activity_object.chapter_id) - .order_by(ChapterActivity.order) + .order_by(ChapterActivity.order) # type: ignore ) chapter_activities = db_session.exec(statement).all() diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py new file mode 100644 index 00000000..9bbd5717 --- /dev/null +++ b/apps/api/src/services/courses/activities/assignments.py @@ -0,0 +1,997 @@ +#################################################### +# CRUD +#################################################### + +from datetime import datetime +from typing import Literal +from uuid import uuid4 +from fastapi import HTTPException, Request +from sqlmodel import Session, select + +from src.db.courses.assignments import ( + Assignment, + AssignmentCreate, + AssignmentRead, + AssignmentTask, + AssignmentTaskCreate, + AssignmentTaskRead, + AssignmentTaskSubmission, + AssignmentTaskSubmissionCreate, + AssignmentTaskSubmissionRead, + AssignmentTaskUpdate, + AssignmentUpdate, + AssignmentUserSubmission, + AssignmentUserSubmissionCreate, + AssignmentUserSubmissionRead, +) +from src.db.courses.courses import Course +from src.db.users import AnonymousUser, PublicUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_if_element_is_public, + authorization_verify_if_user_is_anon, +) + +## > Assignments CRUD + + +async def create_assignment( + request: Request, + assignment_object: AssignmentCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if org exists + statement = select(Course).where(Course.id == assignment_object.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "create", db_session) + + # Create Assignment + assignment = Assignment(**assignment_object.model_dump()) + + assignment.assignment_uuid = str(f"assignment_{uuid4()}") + assignment.creation_date = str(datetime.now()) + assignment.update_date = str(datetime.now()) + assignment.org_id = course.org_id + + # Insert Assignment in DB + db_session.add(assignment) + db_session.commit() + db_session.refresh(assignment) + + # return assignment read + return AssignmentRead.model_validate(assignment) + + +async def read_assignment( + request: Request, + assignment_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment exists + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment read + return AssignmentRead.model_validate(assignment) + + +async def update_assignment( + request: Request, + assignment_uuid: str, + assignment_object: AssignmentUpdate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment exists + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Update only the fields that were passed in + for var, value in vars(assignment_object).items(): + if value is not None: + setattr(assignment, var, value) + assignment.update_date = str(datetime.now()) + + # Insert Assignment in DB + db_session.add(assignment) + db_session.commit() + db_session.refresh(assignment) + + # return assignment read + return AssignmentRead.model_validate(assignment) + + +async def delete_assignment( + request: Request, + assignment_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment exists + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + # Delete Assignment + db_session.delete(assignment) + db_session.commit() + + return {"message": "Assignment deleted"} + + +## > Assignments Tasks CRUD + + +async def create_assignment_task( + request: Request, + assignment_uuid: str, + assignment_task_object: AssignmentTaskCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment exists + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "create", db_session) + + # Create Assignment Task + assignment_task = AssignmentTask(**assignment_task_object.model_dump()) + + assignment_task.assignment_task_uuid = str(f"assignmenttask_{uuid4()}") + assignment_task.creation_date = str(datetime.now()) + assignment_task.update_date = str(datetime.now()) + assignment_task.org_id = course.org_id + + # Insert Assignment Task in DB + db_session.add(assignment_task) + db_session.commit() + db_session.refresh(assignment_task) + + # return assignment task read + return AssignmentTaskRead.model_validate(assignment_task) + + +async def read_assignment_tasks( + request: Request, + assignment_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Find assignment + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Find assignments tasks for an assignment + statement = select(AssignmentTask).where( + assignment.assignment_uuid == assignment_uuid + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment tasks read + return [ + AssignmentTaskRead.model_validate(assignment_task) + for assignment_task in db_session.exec(statement).all() + ] + + +async def update_assignment_task( + request: Request, + assignment_task_uuid: str, + assignment_task_object: AssignmentTaskUpdate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.assignment_task_uuid == assignment_task_uuid + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Update only the fields that were passed in + for var, value in vars(assignment_task_object).items(): + if value is not None: + setattr(assignment_task, var, value) + assignment_task.update_date = str(datetime.now()) + + # Insert Assignment Task in DB + db_session.add(assignment_task) + db_session.commit() + db_session.refresh(assignment_task) + + # return assignment task read + return AssignmentTaskRead.model_validate(assignment_task) + + +async def delete_assignment_task( + request: Request, + assignment_task_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.assignment_task_uuid == assignment_task_uuid + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + # Delete Assignment Task + db_session.delete(assignment_task) + db_session.commit() + + return {"message": "Assignment Task deleted"} + + +## > Assignments Tasks Submissions CRUD + + +async def create_assignment_task_submission( + request: Request, + assignment_task_uuid: str, + assignment_task_submission_object: AssignmentTaskSubmissionCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.assignment_task_uuid == assignment_task_uuid + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "create", db_session) + + # Create Assignment Task Submission + assignment_task_submission = AssignmentTaskSubmission( + **assignment_task_submission_object.model_dump() + ) + + assignment_task_submission.assignment_task_submission_uuid = str( + f"assignmenttasksubmission_{uuid4()}" + ) + assignment_task_submission.creation_date = str(datetime.now()) + assignment_task_submission.update_date = str(datetime.now()) + assignment_task_submission.org_id = course.org_id + + # Insert Assignment Task Submission in DB + db_session.add(assignment_task_submission) + db_session.commit() + db_session.refresh(assignment_task_submission) + + # return assignment task submission read + return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) + + +async def read_user_assignment_task_submissions( + request: Request, + assignment_task_submission_uuid: str, + user_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task submission exists + statement = select(AssignmentTaskSubmission).where( + AssignmentTaskSubmission.assignment_task_submission_uuid + == assignment_task_submission_uuid, + AssignmentTaskSubmission.user_id == user_id, + ) + assignment_task_submission = db_session.exec(statement).first() + + if not assignment_task_submission: + raise HTTPException( + status_code=404, + detail="Assignment Task Submission not found", + ) + + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.id == assignment_task_submission.assignment_task_id + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment task submission read + return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) + + +async def read_assignment_task_submissions( + request: Request, + assignment_task_submission_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task submission exists + statement = select(AssignmentTaskSubmission).where( + AssignmentTaskSubmission.assignment_task_submission_uuid + == assignment_task_submission_uuid, + ) + assignment_task_submission = db_session.exec(statement).first() + + if not assignment_task_submission: + raise HTTPException( + status_code=404, + detail="Assignment Task Submission not found", + ) + + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.id == assignment_task_submission.assignment_task_id + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment task submission read + return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) + + +async def update_assignment_task_submission( + request: Request, + assignment_task_submission_uuid: str, + assignment_task_submission_object: AssignmentTaskSubmissionCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task submission exists + statement = select(AssignmentTaskSubmission).where( + AssignmentTaskSubmission.assignment_task_submission_uuid + == assignment_task_submission_uuid + ) + assignment_task_submission = db_session.exec(statement).first() + + if not assignment_task_submission: + raise HTTPException( + status_code=404, + detail="Assignment Task Submission not found", + ) + + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.id == assignment_task_submission.assignment_task_id + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Update only the fields that were passed in + for var, value in vars(assignment_task_submission_object).items(): + if value is not None: + setattr(assignment_task_submission, var, value) + assignment_task_submission.update_date = str(datetime.now()) + + # Insert Assignment Task Submission in DB + db_session.add(assignment_task_submission) + db_session.commit() + db_session.refresh(assignment_task_submission) + + # return assignment task submission read + return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) + + +async def delete_assignment_task_submission( + request: Request, + assignment_task_submission_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment task submission exists + statement = select(AssignmentTaskSubmission).where( + AssignmentTaskSubmission.assignment_task_submission_uuid + == assignment_task_submission_uuid + ) + assignment_task_submission = db_session.exec(statement).first() + + if not assignment_task_submission: + raise HTTPException( + status_code=404, + detail="Assignment Task Submission not found", + ) + + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.id == assignment_task_submission.assignment_task_id + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + # Delete Assignment Task Submission + db_session.delete(assignment_task_submission) + db_session.commit() + + return {"message": "Assignment Task Submission deleted"} + + +## > Assignments Submissions CRUD + + +async def create_assignment_submission( + request: Request, + assignment_uuid: str, + assignment_user_submission_object: AssignmentUserSubmissionCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment exists + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if the submission has already been made + statement = select(AssignmentUserSubmission).where( + AssignmentUserSubmission.assignment_id == assignment.id, + AssignmentUserSubmission.user_id == assignment_user_submission_object.user_id, + ) + + assignment_user_submission = db_session.exec(statement).first() + + if assignment_user_submission: + raise HTTPException( + status_code=400, + detail="Assignment User Submission already exists", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "create", db_session) + + # Create Assignment User Submission + assignment_user_submission = AssignmentUserSubmission( + **assignment_user_submission_object.model_dump() + ) + + assignment_user_submission.assignment_user_submission_uuid = str( + f"assignmentusersubmission_{uuid4()}" + ) + assignment_user_submission.creation_date = str(datetime.now()) + assignment_user_submission.update_date = str(datetime.now()) + assignment_user_submission.org_id = course.org_id + + # Insert Assignment User Submission in DB + db_session.add(assignment_user_submission) + db_session.commit() + + # return assignment user submission read + return AssignmentUserSubmissionRead.model_validate(assignment_user_submission) + + +async def read_assignment_submissions( + request: Request, + assignment_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Find assignment + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Find assignments tasks for an assignment + statement = select(AssignmentUserSubmission).where( + assignment.assignment_uuid == assignment_uuid + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment tasks read + return [ + AssignmentUserSubmissionRead.model_validate(assignment_user_submission) + for assignment_user_submission in db_session.exec(statement).all() + ] + + +async def read_user_assignment_submissions( + request: Request, + assignment_uuid: str, + user_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Find assignment + statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Find assignments tasks for an assignment + statement = select(AssignmentUserSubmission).where( + assignment.assignment_uuid == assignment_uuid, + AssignmentUserSubmission.user_id == user_id, + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment tasks read + return [ + AssignmentUserSubmissionRead.model_validate(assignment_user_submission) + for assignment_user_submission in db_session.exec(statement).all() + ] + + +async def update_assignment_submission( + request: Request, + user_id: str, + assignment_user_submission_object: AssignmentUserSubmissionCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment user submission exists + statement = select(AssignmentUserSubmission).where( + AssignmentUserSubmission.user_id == user_id + ) + assignment_user_submission = db_session.exec(statement).first() + + if not assignment_user_submission: + raise HTTPException( + status_code=404, + detail="Assignment User Submission not found", + ) + + # Check if assignment exists + statement = select(Assignment).where( + Assignment.id == assignment_user_submission.assignment_id + ) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Update only the fields that were passed in + for var, value in vars(assignment_user_submission_object).items(): + if value is not None: + setattr(assignment_user_submission, var, value) + assignment_user_submission.update_date = str(datetime.now()) + + # Insert Assignment User Submission in DB + db_session.add(assignment_user_submission) + db_session.commit() + db_session.refresh(assignment_user_submission) + + # return assignment user submission read + return AssignmentUserSubmissionRead.model_validate(assignment_user_submission) + + +async def delete_assignment_submission( + request: Request, + user_id: str, + assignment_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if assignment user submission exists + statement = select(AssignmentUserSubmission).where( + AssignmentUserSubmission.user_id == user_id, + AssignmentUserSubmission.assignment_id == assignment_id, + ) + assignment_user_submission = db_session.exec(statement).first() + + if not assignment_user_submission: + raise HTTPException( + status_code=404, + detail="Assignment User Submission not found", + ) + + # Check if assignment exists + statement = select(Assignment).where( + Assignment.id == assignment_user_submission.assignment_id + ) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + # Delete Assignment User Submission + db_session.delete(assignment_user_submission) + db_session.commit() + + return {"message": "Assignment User Submission deleted"} + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + + if action == "read": + if current_user.id == 0: # Anonymous user + res = await authorization_verify_if_element_is_public( + request, course_uuid, action, db_session + ) + return res + else: + res = ( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( + request, current_user.id, action, course_uuid, db_session + ) + ) + return res + else: + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship_and_usergroups( + request, + current_user.id, + action, + course_uuid, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index bb97da54..30b4db9d 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -1,20 +1,20 @@ from typing import Literal -from src.db.courses import Course +from src.db.courses.courses import Course from src.db.organizations import Organization from sqlmodel import Session, select from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) -from src.db.chapters import Chapter -from src.db.activities import ( +from src.db.courses.chapters import Chapter +from src.db.courses.activities import ( Activity, ActivityRead, ActivitySubTypeEnum, ActivityTypeEnum, ) -from src.db.chapter_activities import ChapterActivity -from src.db.course_chapters import CourseChapter +from src.db.courses.chapter_activities import ChapterActivity +from src.db.courses.course_chapters import CourseChapter from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.pdfs import upload_pdf from fastapi import HTTPException, status, UploadFile, Request diff --git a/apps/api/src/services/courses/activities/utils.py b/apps/api/src/services/courses/activities/utils.py index c2904d18..d3fee6d4 100644 --- a/apps/api/src/services/courses/activities/utils.py +++ b/apps/api/src/services/courses/activities/utils.py @@ -1,5 +1,5 @@ -from src.db.activities import ActivityRead -from src.db.courses import CourseRead +from src.db.courses.activities import ActivityRead +from src.db.courses.courses import CourseRead def structure_activity_content_by_type(activity): diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 629a76c7..3396607c 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,5 +1,5 @@ from typing import Literal -from src.db.courses import Course +from src.db.courses.courses import Course from src.db.organizations import Organization from pydantic import BaseModel @@ -8,15 +8,15 @@ from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) -from src.db.chapters import Chapter -from src.db.activities import ( +from src.db.courses.chapters import Chapter +from src.db.courses.activities import ( Activity, ActivityRead, ActivitySubTypeEnum, ActivityTypeEnum, ) -from src.db.chapter_activities import ChapterActivity -from src.db.course_chapters import CourseChapter +from src.db.courses.chapter_activities import ChapterActivity +from src.db.courses.course_chapters import CourseChapter from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.videos import upload_video from fastapi import HTTPException, status, UploadFile, Request diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index ee8ce1b5..b960e2c1 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -8,10 +8,10 @@ from src.security.rbac.rbac import ( authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) -from src.db.course_chapters import CourseChapter -from src.db.activities import Activity, ActivityRead -from src.db.chapter_activities import ChapterActivity -from src.db.chapters import ( +from src.db.courses.course_chapters import CourseChapter +from src.db.courses.activities import Activity, ActivityRead +from src.db.courses.chapter_activities import ChapterActivity +from src.db.courses.chapters import ( Chapter, ChapterCreate, ChapterRead, diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index ac9627b6..9c5f8412 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -15,7 +15,7 @@ from src.db.collections import ( CollectionUpdate, ) from src.db.collections_courses import CollectionCourse -from src.db.courses import Course +from src.db.courses.courses import Course from src.services.users.users import PublicUser from fastapi import HTTPException, status, Request diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 6c09ea9c..b3d2ed33 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -8,7 +8,7 @@ from src.db.organizations import Organization from src.services.trail.trail import get_user_trail_with_orgid from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.users import PublicUser, AnonymousUser, User, UserRead -from src.db.courses import ( +from src.db.courses.courses import ( Course, CourseCreate, CourseRead, diff --git a/apps/api/src/services/courses/updates.py b/apps/api/src/services/courses/updates.py index b4460664..f3fea858 100644 --- a/apps/api/src/services/courses/updates.py +++ b/apps/api/src/services/courses/updates.py @@ -3,13 +3,13 @@ from typing import List from uuid import uuid4 from fastapi import HTTPException, Request, status from sqlmodel import Session, col, select -from src.db.course_updates import ( +from src.db.courses.course_updates import ( CourseUpdate, CourseUpdateCreate, CourseUpdateRead, CourseUpdateUpdate, ) -from src.db.courses import Course +from src.db.courses.courses import Course from src.db.organizations import Organization from src.db.users import AnonymousUser, PublicUser from src.services.courses.courses import rbac_check diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 6dd5c15b..3101ceca 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -1,10 +1,10 @@ from datetime import datetime from uuid import uuid4 -from src.db.chapter_activities import ChapterActivity +from src.db.courses.chapter_activities import ChapterActivity from fastapi import HTTPException, Request, status from sqlmodel import Session, select -from src.db.activities import Activity -from src.db.courses import Course +from src.db.courses.activities import Activity +from src.db.courses.courses import Course from src.db.trail_runs import TrailRun, TrailRunRead from src.db.trail_steps import TrailStep from src.db.trails import Trail, TrailCreate, TrailRead From 9cf84b959d4df7c7d6f29146f44f6ff00469cf32 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 11 Jul 2024 18:37:24 +0200 Subject: [PATCH 03/25] feat: add assignment activity to new activity modal --- .../Buttons/NewActivityButton.tsx | 1 + .../Modals/Activities/Create/NewActivity.tsx | 127 ++++++++---------- .../Create/NewActivityModal/Assignment.tsx | 9 ++ .../assignment-page-activity.png | Bin 0 -> 941 bytes 4 files changed, 63 insertions(+), 74 deletions(-) create mode 100644 apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx create mode 100644 apps/web/public/activities_types/assignment-page-activity.png diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx b/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx index afa30d9a..77d02ff0 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseStructure/Buttons/NewActivityButton.tsx @@ -87,6 +87,7 @@ function NewActivityButton(props: NewActivityButtonProps) { isDialogOpen={newActivityModal} onOpenChange={setNewActivityModal} minHeight="no-min" + minWidth='md' addDefCloseButton={false} dialogContent={ + <> {selectedView === 'home' && ( - +
{ setSelectedView('dynamic') }} > - - Dynamic Page - - Dynamic Page +
+ Dynamic Page +
+
+ Dynamic Page +
{ setSelectedView('video') }} > - - Video Page - - Video Page +
+ Video Page +
+
+ Video +
{ setSelectedView('documentpdf') }} > - - Document PDF Page - - PDF Document Page +
+ Document PDF Page +
+
+ Document +
- + { + setSelectedView('assignments') + }} + > +
+ Assignment Page +
+
+ Assignments +
+
+
)} {selectedView === 'dynamic' && ( @@ -82,63 +99,25 @@ function NewActivityModal({ course={course} /> )} - + + {selectedView === 'assignments' && ( + ) + } + ) } -const ActivityChooserWrapper = styled('div', { - display: 'flex', - flexDirection: 'row', - justifyContent: 'start', - marginTop: 10, -}) - -const ActivityOption = styled('div', { - width: '180px', - textAlign: 'center', - borderRadius: 10, - background: '#F6F6F6', - border: '4px solid #F5F5F5', - margin: 'auto', - - // hover - '&:hover': { - cursor: 'pointer', - background: '#ededed', - border: '4px solid #ededed', - - transition: 'background 0.2s ease-in-out, border 0.2s ease-in-out', - }, -}) - -const ActivityTypeImage = styled('div', { - height: 80, - borderRadius: 8, - margin: 2, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'end', - textAlign: 'center', - background: '#ffffff', - - // hover - '&:hover': { - cursor: 'pointer', - }, -}) - -const ActivityTypeTitle = styled('div', { - display: 'flex', - fontSize: 12, - height: '20px', - fontWeight: 500, - color: 'rgba(0, 0, 0, 0.38);', - - // center text vertically - alignItems: 'center', - justifyContent: 'center', - textAlign: 'center', -}) +const ActivityOption = ({ onClick, children }: any) => ( +
+ {children} +
+) export default NewActivityModal diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx new file mode 100644 index 00000000..a8eccc51 --- /dev/null +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +function Assignment() { + return ( +
Assignment
+ ) +} + +export default Assignment \ No newline at end of file diff --git a/apps/web/public/activities_types/assignment-page-activity.png b/apps/web/public/activities_types/assignment-page-activity.png new file mode 100644 index 0000000000000000000000000000000000000000..46de7b825d62f20856c91d66c1119fd1116c769d GIT binary patch literal 941 zcmeAS@N?(olHy`uVBq!ia0vp^=|F7H!3HG%&bgKhq&N#aB8wRqxP?KOkzv*x37{Zj zage(c!@6@aFM%AEbVpxD28NCO+ zDsRem`#TEMc$?X)uTNiWGwHfT-~P75AJ#v#pDhp6G*Pj8_Wbit)s96ks*_%I2i~aQ z{`R{zN5o3NR&{Z}q?FC+Q=NNGDY`usSX|T*XnACXOi~D+p;o&Ds_>Mv><`6Kkvs^84k(*<10m`2dzx0+B`aHho z8J1r@x#s%ooZFMuyG|0gDWD(~;6GtkO@@iovb%ZR-uaK7IPheh+WV3#>B3%5Ar-x~ zx?6r ztIYjJ{D#w&4xK7&OxUyScJ8m@Grml@QO>;|-kuU*t<>v0nf!F~J?@Kcd|dmjO`9Xr zZWlhy+#2=rG`n?l@8=tOd@K1fC-B}n;mDoi`08UPOMHRe_NCio6{NRJOzyT{_GkT7 zmg=wZOy3<{z1b|(?TXd?Z!UO$&EUXvhm^#|w_$r5S{4;}5IET^woo{^qj%>RXDe=K4+RTetP}_euFG0tsSJ z?7!^hv6y+h@K4a8?Y;@hH$P@gac>s-%qIWg6MJmS#>t)9>U)8?i^0>?&t;ucLK6Vv C#iU{Y literal 0 HcmV?d00001 From 04c05e4f9aae1395ef6b7d33cbe1581efe5c42cc Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 11 Jul 2024 19:57:12 +0200 Subject: [PATCH 04/25] feat: init alembic + add init revision --- apps/api/alembic.ini | 116 ++++++++++++++++++ apps/api/migrations/env.py | 110 +++++++++++++++++ apps/api/migrations/script.py.mako | 27 ++++ .../df2981bf24dd_initial_migration.py | 51 ++++++++ apps/api/poetry.lock | 40 +++++- apps/api/pyproject.toml | 1 + apps/api/src/db/courses/activities.py | 11 +- 7 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 apps/api/alembic.ini create mode 100644 apps/api/migrations/env.py create mode 100644 apps/api/migrations/script.py.mako create mode 100644 apps/api/migrations/versions/df2981bf24dd_initial_migration.py diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 00000000..58e115a0 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://learnhouse:learnhouse@localhost:5432/learnhouse + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 00000000..41302d3d --- /dev/null +++ b/apps/api/migrations/env.py @@ -0,0 +1,110 @@ +import importlib +from logging.config import fileConfig +import os + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel +from alembic import context + +from config.config import get_learnhouse_config + +# LearnHouse config + +lh_config = get_learnhouse_config() + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +# IMPORTING ALL SCHEMAS +base_dir = 'src/db' +base_module_path = 'src.db' + +# Recursively walk through the base directory +for root, dirs, files in os.walk(base_dir): + # Filter out __init__.py and non-Python files + module_files = [f for f in files if f.endswith('.py') and f != '__init__.py'] + # Calculate the module's base path from its directory structure + path_diff = os.path.relpath(root, base_dir) + if path_diff == '.': + # Root of the base_dir, no additional path to add + current_module_base = base_module_path + else: + # Convert directory path to a module path + current_module_base = f"{base_module_path}.{path_diff.replace(os.sep, '.')}" + + # Dynamically import each module + for file_name in module_files: + module_name = file_name[:-3] # Remove the '.py' extension + full_module_path = f"{current_module_base}.{module_name}" + importlib.import_module(full_module_path) + +# IMPORTING ALL SCHEMAS + +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 00000000..6ce33510 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/migrations/versions/df2981bf24dd_initial_migration.py b/apps/api/migrations/versions/df2981bf24dd_initial_migration.py new file mode 100644 index 00000000..e27b5c33 --- /dev/null +++ b/apps/api/migrations/versions/df2981bf24dd_initial_migration.py @@ -0,0 +1,51 @@ +"""Initial Migration + +Revision ID: df2981bf24dd +Revises: +Create Date: 2024-07-11 19:33:37.993767 + +""" +from typing import Sequence, Union + +from alembic import op +from grpc import server +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'df2981bf24dd' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('activity', sa.Column('published', sa.Boolean(), nullable=False, server_default=sa.true())) + op.drop_column('activity', 'published_version') + op.drop_column('activity', 'version') + + op.drop_column('assignmentusersubmission', 'assignment_user_uuid') + + op.drop_constraint('trail_org_id_fkey', 'trail', type_='foreignkey') + op.create_foreign_key('trail_org_id_fkey', 'trail', 'organization', ['org_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('trail_user_id_fkey', 'trail', type_='foreignkey') + op.create_foreign_key('trail_user_id_fkey', 'trail', 'user', ['user_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('trail_org_id_fkey', 'trail', type_='foreignkey') + op.create_foreign_key('trail_org_id_fkey', 'trail', 'organization', ['org_id'], ['id']) + op.drop_constraint('trail_user_id_fkey', 'trail', type_='foreignkey') + op.create_foreign_key('trail_user_id_fkey', 'trail', 'user', ['user_id'], ['id']) + + op.add_column('assignmentusersubmission', sa.Column('assignment_user_uuid', sa.VARCHAR(), autoincrement=False, nullable=False)) + + op.add_column('activity', sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False , server_default=sa.text('1'))) + op.add_column('activity', sa.Column('published_version', sa.INTEGER(), autoincrement=False, nullable=False , server_default=sa.text('1')) ) + op.drop_column('activity', 'published') + # ### end Alembic commands ### diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index deb41715..5aae9088 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -109,6 +109,25 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "alembic" +version = "1.13.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"}, + {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] + [[package]] name = "anyio" version = "4.4.0" @@ -1419,6 +1438,25 @@ files = [ pydantic = ">=1,<3" requests = ">=2,<3" +[[package]] +name = "mako" +version = "1.3.5" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"}, + {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -3833,4 +3871,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "2ede6d1fb6efc6ff9e32b19be61139c9bb9fca4a9c84ac290a8278ea7812aed8" +content-hash = "76c4defc807fe83375766ac085982a2edf16e57b2d092a4494021f00a0424a4c" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 1ea47def..0632d60b 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -38,6 +38,7 @@ tiktoken = "^0.7.0" uvicorn = "0.30.1" typer = "^0.12.3" chromadb = "^0.5.3" +alembic = "^1.13.2" [build-system] build-backend = "poetry.core.masonry.api" diff --git a/apps/api/src/db/courses/activities.py b/apps/api/src/db/courses/activities.py index 3db42b2a..8e40e21a 100644 --- a/apps/api/src/db/courses/activities.py +++ b/apps/api/src/db/courses/activities.py @@ -8,7 +8,7 @@ class ActivityTypeEnum(str, Enum): TYPE_VIDEO = "TYPE_VIDEO" TYPE_DOCUMENT = "TYPE_DOCUMENT" TYPE_DYNAMIC = "TYPE_DYNAMIC" - TYPE_ASSESSMENT = "TYPE_ASSESSMENT" + TYPE_ASSIGNMENT = "TYPE_ASSIGNMENT" TYPE_CUSTOM = "TYPE_CUSTOM" @@ -21,8 +21,8 @@ class ActivitySubTypeEnum(str, Enum): # Document SUBTYPE_DOCUMENT_PDF = "SUBTYPE_DOCUMENT_PDF" SUBTYPE_DOCUMENT_DOC = "SUBTYPE_DOCUMENT_DOC" - # Assessment - SUBTYPE_ASSESSMENT_QUIZ = "SUBTYPE_ASSESSMENT_QUIZ" + # Assignment + SUBTYPE_ASSIGNMENT_ANY = "SUBTYPE_ASSIGNMENT_ANY" # Custom SUBTYPE_CUSTOM = "SUBTYPE_CUSTOM" @@ -32,8 +32,7 @@ class ActivityBase(SQLModel): activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM content: dict = Field(default={}, sa_column=Column(JSON)) - published_version: int - version: int + published: bool = False class Activity(ActivityBase, table=True): @@ -57,8 +56,6 @@ class ActivityCreate(ActivityBase): class ActivityUpdate(ActivityBase): name: Optional[str] - activity_type: Optional[ActivityTypeEnum] - activity_sub_type: Optional[ActivitySubTypeEnum] content: dict = Field(default={}, sa_column=Column(JSON)) published_version: Optional[int] version: Optional[int] From 10e9be1d3377064a34f6e5bdf2df98458327bb33 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 12 Jul 2024 11:54:33 +0200 Subject: [PATCH 05/25] feat: create and delete assignment activities from UI --- apps/api/migrations/env.py | 2 +- .../versions/6295e05ff7d0_enum_updates.py | 41 +++++ .../df2981bf24dd_initial_migration.py | 9 +- apps/api/poetry.lock | 45 ++++- apps/api/pyproject.toml | 2 + apps/api/src/db/collections_courses.py | 8 +- apps/api/src/db/courses/assignments.py | 2 +- apps/api/src/db/trails.py | 6 + apps/api/src/routers/courses/assignments.py | 13 ++ .../courses/activities/assignments.py | 48 ++++++ .../DraggableElements/ActivityElement.tsx | 6 + .../Modals/Activities/Create/NewActivity.tsx | 3 +- .../Create/NewActivityModal/Assignment.tsx | 154 +++++++++++++++++- apps/web/services/courses/assignments.ts | 33 ++++ 14 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 apps/api/migrations/versions/6295e05ff7d0_enum_updates.py create mode 100644 apps/web/services/courses/assignments.ts diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py index 41302d3d..08a2dd30 100644 --- a/apps/api/migrations/env.py +++ b/apps/api/migrations/env.py @@ -1,7 +1,7 @@ import importlib from logging.config import fileConfig import os - +import alembic_postgresql_enum from sqlalchemy import engine_from_config from sqlalchemy import pool from sqlmodel import SQLModel diff --git a/apps/api/migrations/versions/6295e05ff7d0_enum_updates.py b/apps/api/migrations/versions/6295e05ff7d0_enum_updates.py new file mode 100644 index 00000000..4ff2406e --- /dev/null +++ b/apps/api/migrations/versions/6295e05ff7d0_enum_updates.py @@ -0,0 +1,41 @@ +"""Enum updates + +Revision ID: 6295e05ff7d0 +Revises: df2981bf24dd +Create Date: 2024-07-11 20:46:26.582170 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from alembic_postgresql_enum import TableReference # type: ignore + +# revision identifiers, used by Alembic. +revision: str = '6295e05ff7d0' +down_revision: Union[str, None] = 'df2981bf24dd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values('public', 'activitytypeenum', ['TYPE_VIDEO', 'TYPE_DOCUMENT', 'TYPE_DYNAMIC', 'TYPE_ASSIGNMENT', 'TYPE_CUSTOM'], + [TableReference(table_schema='public', table_name='activity', column_name='activity_type')], + enum_values_to_rename=[]) + op.sync_enum_values('public', 'activitysubtypeenum', ['SUBTYPE_DYNAMIC_PAGE', 'SUBTYPE_VIDEO_YOUTUBE', 'SUBTYPE_VIDEO_HOSTED', 'SUBTYPE_DOCUMENT_PDF', 'SUBTYPE_DOCUMENT_DOC', 'SUBTYPE_ASSIGNMENT_ANY', 'SUBTYPE_CUSTOM'], + [TableReference(table_schema='public', table_name='activity', column_name='activity_sub_type')], + enum_values_to_rename=[]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values('public', 'activitysubtypeenum', ['SUBTYPE_DYNAMIC_PAGE', 'SUBTYPE_VIDEO_YOUTUBE', 'SUBTYPE_VIDEO_HOSTED', 'SUBTYPE_DOCUMENT_PDF', 'SUBTYPE_DOCUMENT_DOC', 'SUBTYPE_ASSESSMENT_QUIZ', 'SUBTYPE_CUSTOM'], + [TableReference(table_schema='public', table_name='activity', column_name='activity_sub_type')], + enum_values_to_rename=[]) + op.sync_enum_values('public', 'activitytypeenum', ['TYPE_VIDEO', 'TYPE_DOCUMENT', 'TYPE_DYNAMIC', 'TYPE_ASSESSMENT', 'TYPE_CUSTOM'], + [TableReference(table_schema='public', table_name='activity', column_name='activity_type')], + enum_values_to_rename=[]) + # ### end Alembic commands ### diff --git a/apps/api/migrations/versions/df2981bf24dd_initial_migration.py b/apps/api/migrations/versions/df2981bf24dd_initial_migration.py index e27b5c33..a6a5be8a 100644 --- a/apps/api/migrations/versions/df2981bf24dd_initial_migration.py +++ b/apps/api/migrations/versions/df2981bf24dd_initial_migration.py @@ -23,6 +23,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column('activity', sa.Column('published', sa.Boolean(), nullable=False, server_default=sa.true())) + # If you need to rename columns instead of dropping them, use the rename_column command + # For example, if we are changing the name 'published_version' to 'published', we would use: + # op.alter_column('activity', 'published_version', new_column_name='published', existing_type=sa.Boolean()) op.drop_column('activity', 'published_version') op.drop_column('activity', 'version') @@ -32,7 +35,6 @@ def upgrade() -> None: op.create_foreign_key('trail_org_id_fkey', 'trail', 'organization', ['org_id'], ['id'], ondelete='CASCADE') op.drop_constraint('trail_user_id_fkey', 'trail', type_='foreignkey') op.create_foreign_key('trail_user_id_fkey', 'trail', 'user', ['user_id'], ['id'], ondelete='CASCADE') - # ### end Alembic commands ### @@ -45,7 +47,8 @@ def downgrade() -> None: op.add_column('assignmentusersubmission', sa.Column('assignment_user_uuid', sa.VARCHAR(), autoincrement=False, nullable=False)) - op.add_column('activity', sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False , server_default=sa.text('1'))) - op.add_column('activity', sa.Column('published_version', sa.INTEGER(), autoincrement=False, nullable=False , server_default=sa.text('1')) ) + op.add_column('activity', sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False, server_default=sa.text('1'))) + op.add_column('activity', sa.Column('published_version', sa.INTEGER(), autoincrement=False, nullable=False, server_default=sa.text('1'))) op.drop_column('activity', 'published') # ### end Alembic commands ### + diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index 5aae9088..2b49d1cf 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -128,6 +128,21 @@ typing-extensions = ">=4" [package.extras] tz = ["backports.zoneinfo"] +[[package]] +name = "alembic-postgresql-enum" +version = "1.2.0" +description = "Alembic autogenerate support for creation, alteration and deletion of enums" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "alembic_postgresql_enum-1.2.0-py3-none-any.whl", hash = "sha256:bd156e882a10c680fc88ebad25cfe78ccf9f826dec89670f8aeb28e5359e502b"}, + {file = "alembic_postgresql_enum-1.2.0.tar.gz", hash = "sha256:971bd3a4c35ea38869bb5e263ea79e5b4a9c4a02f174a3dd7ddcb29d41260cba"}, +] + +[package.dependencies] +alembic = ">=1.7" +SQLAlchemy = ">=1.4" + [[package]] name = "anyio" version = "4.4.0" @@ -3018,6 +3033,34 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "sqlalchemy-utils" +version = "0.41.2" +description = "Various utility functions for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990"}, + {file = "SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e"}, +] + +[package.dependencies] +SQLAlchemy = ">=1.3" + +[package.extras] +arrow = ["arrow (>=0.3.4)"] +babel = ["Babel (>=1.3)"] +color = ["colour (>=0.0.4)"] +encrypted = ["cryptography (>=0.6)"] +intervals = ["intervals (>=0.7.1)"] +password = ["passlib (>=1.6,<2.0)"] +pendulum = ["pendulum (>=2.0.5)"] +phone = ["phonenumbers (>=5.9.2)"] +test = ["Jinja2 (>=2.3)", "Pygments (>=1.2)", "backports.zoneinfo", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "isort (>=4.2.2)", "pg8000 (>=1.12.4)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] +test-all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "backports.zoneinfo", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] +timezone = ["python-dateutil"] +url = ["furl (>=0.4.1)"] + [[package]] name = "sqlmodel" version = "0.0.19" @@ -3871,4 +3914,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "76c4defc807fe83375766ac085982a2edf16e57b2d092a4494021f00a0424a4c" +content-hash = "49d72c6871e3ecffae3b55ccad3a6b140f9a1ebbca84d7632dafd54e1d2b7f9d" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 0632d60b..a742eca1 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -39,6 +39,8 @@ uvicorn = "0.30.1" typer = "^0.12.3" chromadb = "^0.5.3" alembic = "^1.13.2" +alembic-postgresql-enum = "^1.2.0" +sqlalchemy-utils = "^0.41.2" [build-system] build-backend = "poetry.core.masonry.api" diff --git a/apps/api/src/db/collections_courses.py b/apps/api/src/db/collections_courses.py index 4e0fc270..9ea829d8 100644 --- a/apps/api/src/db/collections_courses.py +++ b/apps/api/src/db/collections_courses.py @@ -5,8 +5,12 @@ from sqlmodel import Field, SQLModel class CollectionCourse(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - collection_id: int = Field(sa_column=Column(Integer, ForeignKey("collection.id", ondelete="CASCADE"))) - course_id: int = Field(sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))) + collection_id: int = Field( + sa_column=Column(Integer, ForeignKey("collection.id", ondelete="CASCADE")) + ) + course_id: int = Field( + sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")) + ) org_id: int = Field(default=None, foreign_key="organization.id") creation_date: str update_date: str diff --git a/apps/api/src/db/courses/assignments.py b/apps/api/src/db/courses/assignments.py index 78632aa2..67b1f872 100644 --- a/apps/api/src/db/courses/assignments.py +++ b/apps/api/src/db/courses/assignments.py @@ -87,7 +87,7 @@ class Assignment(AssignmentBase, table=True): class AssignmentTaskTypeEnum(str, Enum): FILE_SUBMISSION = "FILE_SUBMISSION" QUIZ = "QUIZ" - FORM = "FORM" # soon to be implemented + FORM = "FORM" # soon to be implemented OTHER = "OTHER" diff --git a/apps/api/src/db/trails.py b/apps/api/src/db/trails.py index afce3b7d..9b0430ba 100644 --- a/apps/api/src/db/trails.py +++ b/apps/api/src/db/trails.py @@ -16,6 +16,12 @@ class TrailBase(SQLModel): class Trail(TrailBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) + ) + user_id: int = Field( + sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + ) trail_uuid: str = "" creation_date: str = "" update_date: str = "" diff --git a/apps/api/src/routers/courses/assignments.py b/apps/api/src/routers/courses/assignments.py index 6bcd334e..1dba87ce 100644 --- a/apps/api/src/routers/courses/assignments.py +++ b/apps/api/src/routers/courses/assignments.py @@ -17,6 +17,7 @@ from src.services.courses.activities.assignments import ( create_assignment_task, create_assignment_task_submission, delete_assignment, + delete_assignment_from_activity_uuid, delete_assignment_submission, delete_assignment_task, delete_assignment_task_submission, @@ -90,6 +91,18 @@ async def api_delete_assignment( """ return await delete_assignment(request, assignment_uuid, current_user, db_session) +@router.delete("/activity/{activity_uuid}") +async def api_delete_assignment_from_activity( + request: Request, + activity_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Delete an assignment + """ + return await delete_assignment_from_activity_uuid(request, activity_uuid, current_user, db_session) + ## ASSIGNMENTS Tasks ## diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index 9bbd5717..cb133190 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -8,6 +8,7 @@ from uuid import uuid4 from fastapi import HTTPException, Request from sqlmodel import Session, select +from src.db.courses.activities import Activity from src.db.courses.assignments import ( Assignment, AssignmentCreate, @@ -184,6 +185,53 @@ async def delete_assignment( return {"message": "Assignment deleted"} +async def delete_assignment_from_activity_uuid( + request: Request, + activity_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if activity exists + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=404, + detail="Activity not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.activity_id == activity.id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + # Delete Assignment + db_session.delete(assignment) + + db_session.commit() + + return {"message": "Assignment deleted"} + ## > Assignments Tasks CRUD diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx b/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx index a76c72e7..a631adcc 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx @@ -19,6 +19,7 @@ import { useRouter } from 'next/navigation' import React from 'react' import { Draggable } from 'react-beautiful-dnd' import { mutate } from 'swr' +import { deleteAssignment, deleteAssignmentUsingActivityUUID } from '@services/courses/assignments' type ActivitiyElementProps = { orgslug: string @@ -45,6 +46,11 @@ function ActivityElement(props: ActivitiyElementProps) { const activityUUID = props.activity.activity_uuid async function deleteActivityUI() { + // Assignments + if(props.activity.activity_type === 'TYPE_ASSIGNMENT') { + await deleteAssignmentUsingActivityUUID(props.activity.activity_uuid, access_token) + } + await deleteActivity(props.activity.activity_uuid, access_token) mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`) await revalidateTags(['courses'], props.orgslug) diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx index 797a2c8f..16201245 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx @@ -102,9 +102,10 @@ function NewActivityModal({ {selectedView === 'assignments' && ( ) } diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx index a8eccc51..95abf80b 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx @@ -1,9 +1,153 @@ import React from 'react' +import FormLayout, { + ButtonBlack, + Flex, + FormField, + FormLabel, + FormMessage, + Input, -function Assignment() { - return ( -
Assignment
- ) +} from '@components/StyledElements/Form/Form' +import * as Form from '@radix-ui/react-form' +import { BarLoader } from 'react-spinners' +import { useOrg } from '@components/Contexts/OrgContext' +import { getAPIUrl } from '@services/config/config' +import { mutate } from 'swr' +import { createAssignment } from '@services/courses/assignments' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { createActivity } from '@services/courses/activities' + +function NewAssignment({ submitActivity, chapterId, course, closeModal }: any) { + const org = useOrg() as any; + const session = useLHSession() as any + const [activityName, setActivityName] = React.useState('') + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [activityDescription, setActivityDescription] = React.useState('') + const [dueDate, setDueDate] = React.useState('') + const [gradingType, setGradingType] = React.useState('ALPHABET') + + const handleNameChange = (e: any) => { + setActivityName(e.target.value) + } + + const handleDescriptionChange = (e: any) => { + setActivityDescription(e.target.value) + } + + const handleDueDateChange = (e: any) => { + setDueDate(e.target.value) + } + + const handleGradingTypeChange = (e: any) => { + setGradingType(e.target.value) + } + + const handleSubmit = async (e: any) => { + e.preventDefault() + setIsSubmitting(true) + const activity = { + name: activityName, + chapter_id: chapterId, + activity_type: 'TYPE_ASSIGNMENT', + activity_sub_type: 'SUBTYPE_ASSIGNMENT_ANY', + published: false, + course_id: course?.courseStructure.id, + } + + const activity_res = await createActivity(activity, chapterId, org?.id, session.data?.tokens?.access_token) + console.log(course) + console.log(activity_res) + await createAssignment({ + title: activityName, + description: activityDescription, + due_date: dueDate, + grading_type: gradingType, + course_id: course?.courseStructure.id, + org_id: org?.id, + chapter_id: chapterId, + activity_id: activity_res?.id, + }, session.data?.tokens?.access_token) + + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) + setIsSubmitting(false) + closeModal() + } + + + return ( + + + + Assignment Title + + Please provide a name for your assignment + + + + + + + + {/* Description */} + + + Assignment Description + + Please provide a description for your assignment + + + + + + + + {/* Due date */} + + + Due Date + + Please provide a due date for your assignment + + + + + + + + {/* Grading type */} + + + Grading Type + + Please provide a grading type for your assignment + + + + + + + + + + + {isSubmitting ? ( + + ) : ( + 'Create activity' + )} + + + + + ) } -export default Assignment \ No newline at end of file +export default NewAssignment \ No newline at end of file diff --git a/apps/web/services/courses/assignments.ts b/apps/web/services/courses/assignments.ts new file mode 100644 index 00000000..33239fa3 --- /dev/null +++ b/apps/web/services/courses/assignments.ts @@ -0,0 +1,33 @@ +import { getAPIUrl } from '@services/config/config' +import { + RequestBodyWithAuthHeader, + getResponseMetadata, +} from '@services/utils/ts/requests' + +export async function createAssignment(body: any, access_token: string) { + const result: any = await fetch( + `${getAPIUrl()}assignments`, + RequestBodyWithAuthHeader('POST', body, null, access_token) + ) + const res = await getResponseMetadata(result) + return res +} + +// Delete an assignment +export async function deleteAssignment(assignmentUUID: string, access_token: string) { + const result: any = await fetch( + `${getAPIUrl()}assignments/${assignmentUUID}`, + RequestBodyWithAuthHeader('DELETE', null, null, access_token) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function deleteAssignmentUsingActivityUUID(activityUUID: string, access_token: string) { + const result: any = await fetch( + `${getAPIUrl()}assignments/activity/${activityUUID}`, + RequestBodyWithAuthHeader('DELETE', null, null, access_token) + ) + const res = await getResponseMetadata(result) + return res + } From 6a4e16ec2956c60ebf73e3cbc97ff8d44e20a589 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 12 Jul 2024 21:28:50 +0200 Subject: [PATCH 06/25] feat: init assignments UI and fix bugs --- ...95932_add_reference_for_assignmenttasks.py | 31 +++++ apps/api/src/db/courses/activities.py | 8 +- apps/api/src/db/courses/assignments.py | 3 +- apps/api/src/routers/courses/assignments.py | 13 ++ .../courses/activities/assignments.py | 42 ++++++ .../_components/TaskEditor.tsx | 32 +++++ .../[assignmentuuid]/_components/Tasks.tsx | 35 +++++ .../assignments/[assignmentuuid]/page.tsx | 59 +++++++++ .../orgs/[orgslug]/dash/assignments/page.tsx | 9 ++ .../course/[courseuuid]/[subpage]/page.tsx | 1 - .../Assignments/AssignmentContext.tsx | 40 ++++++ .../DraggableElements/ActivityElement.tsx | 121 ++++++++++++++---- .../components/Dashboard/UI/BreadCrumbs.tsx | 19 ++- apps/web/components/Dashboard/UI/LeftMenu.tsx | 10 +- apps/web/services/courses/assignments.ts | 51 ++++++-- apps/web/styles/globals.css | 9 ++ 16 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 apps/api/migrations/versions/d8bc71595932_add_reference_for_assignmenttasks.py create mode 100644 apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx create mode 100644 apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx create mode 100644 apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx create mode 100644 apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx create mode 100644 apps/web/components/Contexts/Assignments/AssignmentContext.tsx diff --git a/apps/api/migrations/versions/d8bc71595932_add_reference_for_assignmenttasks.py b/apps/api/migrations/versions/d8bc71595932_add_reference_for_assignmenttasks.py new file mode 100644 index 00000000..10634418 --- /dev/null +++ b/apps/api/migrations/versions/d8bc71595932_add_reference_for_assignmenttasks.py @@ -0,0 +1,31 @@ +"""Add reference for AssignmentTasks + +Revision ID: d8bc71595932 +Revises: 6295e05ff7d0 +Create Date: 2024-07-12 18:59:50.242716 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'd8bc71595932' +down_revision: Union[str, None] = '6295e05ff7d0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('assignmenttask', sa.Column('reference_file', sa.VARCHAR(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('assignmenttask', 'reference_file') + # ### end Alembic commands ### diff --git a/apps/api/src/db/courses/activities.py b/apps/api/src/db/courses/activities.py index 8e40e21a..d6d8f0d8 100644 --- a/apps/api/src/db/courses/activities.py +++ b/apps/api/src/db/courses/activities.py @@ -29,8 +29,8 @@ class ActivitySubTypeEnum(str, Enum): class ActivityBase(SQLModel): name: str - activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM - activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM + activity_type: ActivityTypeEnum + activity_sub_type: ActivitySubTypeEnum content: dict = Field(default={}, sa_column=Column(JSON)) published: bool = False @@ -51,12 +51,16 @@ class Activity(ActivityBase, table=True): class ActivityCreate(ActivityBase): chapter_id: int + activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM + activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM pass class ActivityUpdate(ActivityBase): name: Optional[str] content: dict = Field(default={}, sa_column=Column(JSON)) + activity_type: Optional[ActivityTypeEnum] + activity_sub_type: Optional[ActivitySubTypeEnum] published_version: Optional[int] version: Optional[int] diff --git a/apps/api/src/db/courses/assignments.py b/apps/api/src/db/courses/assignments.py index 67b1f872..fc288e36 100644 --- a/apps/api/src/db/courses/assignments.py +++ b/apps/api/src/db/courses/assignments.py @@ -97,6 +97,7 @@ class AssignmentTaskBase(SQLModel): title: str description: str hint: str + reference_file: Optional[str] assignment_type: AssignmentTaskTypeEnum contents: Dict = Field(default={}, sa_column=Column(JSON)) max_grade_value: int = 0 # Value is always between 0-100 @@ -108,7 +109,7 @@ class AssignmentTaskBase(SQLModel): activity_id: int -class AssignmentTaskCreate(AssignmentTaskBase ): +class AssignmentTaskCreate(AssignmentTaskBase): """Model for creating a new assignment task.""" pass # Inherits all fields from AssignmentTaskBase diff --git a/apps/api/src/routers/courses/assignments.py b/apps/api/src/routers/courses/assignments.py index 1dba87ce..f627d099 100644 --- a/apps/api/src/routers/courses/assignments.py +++ b/apps/api/src/routers/courses/assignments.py @@ -22,6 +22,7 @@ from src.services.courses.activities.assignments import ( delete_assignment_task, delete_assignment_task_submission, read_assignment, + read_assignment_from_activity_uuid, read_assignment_submissions, read_assignment_task_submissions, read_assignment_tasks, @@ -62,6 +63,18 @@ async def api_read_assignment( """ return await read_assignment(request, assignment_uuid, current_user, db_session) +@router.get("/activity/{activity_uuid}") +async def api_read_assignment_from_activity( + request: Request, + activity_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> AssignmentRead: + """ + Read an assignment + """ + return await read_assignment_from_activity_uuid(request, activity_uuid, current_user, db_session) + @router.put("/{assignment_uuid}") async def api_update_assignment( diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index cb133190..46102cf7 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -104,6 +104,48 @@ async def read_assignment( # return assignment read return AssignmentRead.model_validate(assignment) +async def read_assignment_from_activity_uuid( + request: Request, + activity_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if activity exists + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=404, + detail="Activity not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.activity_id == activity.id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment read + return AssignmentRead.model_validate(assignment) + async def update_assignment( request: Request, diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx new file mode 100644 index 00000000..c76be5e6 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx @@ -0,0 +1,32 @@ +'use client'; +import { Info, Link } from 'lucide-react' +import React from 'react' + +function AssignmentTaskEditor({ task_uuid, page }: any) { + const [selectedSubPage, setSelectedSubPage] = React.useState(page) + return ( +
+ +
+
+ Assignment Test #1 +
+
+
+
+ +
Overview
+
+
+
+
+
+ ) +} + +export default AssignmentTaskEditor \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx new file mode 100644 index 00000000..f2933817 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx @@ -0,0 +1,35 @@ +import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext' +import { Plus } from 'lucide-react'; +import React, { useEffect } from 'react' + +function AssignmentTasks() { + const assignments = useAssignments() as any; + + useEffect(() => { + console.log(assignments) + }, [assignments]) + + + return ( +
+
+ {assignments && assignments?.assignment_tasks?.map((task: any) => { + return ( +
+
+
{task.title}
+
+
+ ) + })} +
+ +

Add Task

+
+
+ +
+ ) +} + +export default AssignmentTasks \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx new file mode 100644 index 00000000..cf7f609a --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx @@ -0,0 +1,59 @@ +'use client'; +import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' +import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' +import { BookOpen, BookOpenCheck, BookX, Check, Ellipsis, EllipsisVertical, GalleryVerticalEnd, Info, LayoutList, UserRoundCog } from 'lucide-react' +import React from 'react' +import AssignmentTaskEditor from './_components/TaskEditor'; +import { AssignmentProvider } from '@components/Contexts/Assignments/AssignmentContext'; +import AssignmentTasks from './_components/Tasks'; +import { useParams } from 'next/navigation'; + +function AssignmentEdit() { + const params = useParams<{ assignmentuuid: string; }>() + return ( +
+
+
+
+ +
+
Assignment Editor
+
+
+
+
+
Published
+
+
+ +

Publish

+
+
+ +

Unpublish

+
+
+
+
+
+
+
+
+ +

Tasks

+
+ + + +
+
+ + + +
+
+
+ ) +} + +export default AssignmentEdit \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx new file mode 100644 index 00000000..c54c9cc9 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/page.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +function AssignmentsHome() { + return ( +
AssignmentsHome
+ ) +} + +export default AssignmentsHome \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 9de28441..9b44a988 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -82,7 +82,6 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) { - diff --git a/apps/web/components/Contexts/Assignments/AssignmentContext.tsx b/apps/web/components/Contexts/Assignments/AssignmentContext.tsx new file mode 100644 index 00000000..f54844ee --- /dev/null +++ b/apps/web/components/Contexts/Assignments/AssignmentContext.tsx @@ -0,0 +1,40 @@ +'use client' +import { getAPIUrl } from '@services/config/config' +import { swrFetcher } from '@services/utils/ts/requests' +import React, { createContext, useContext, useEffect } from 'react' +import useSWR from 'swr' +import { useLHSession } from '@components/Contexts/LHSessionContext' + +export const AssignmentContext = createContext({}) + +export function AssignmentProvider({ children, assignment_uuid }: { children: React.ReactNode, assignment_uuid: string }) { + const session = useLHSession() as any + const accessToken = session?.data?.tokens?.access_token + const [assignmentsFull, setAssignmentsFull] = React.useState({ assignment_object: null, assignment_tasks: null }) + + const { data: assignment, error: assignmentError } = useSWR( + `${getAPIUrl()}assignments/${assignment_uuid}`, + (url) => swrFetcher(url, accessToken) + ) + + const { data: assignment_tasks, error: assignmentTasksError } = useSWR( + `${getAPIUrl()}assignments/${assignment_uuid}/tasks`, + (url) => swrFetcher(url, accessToken) + ) + + useEffect(() => { + setAssignmentsFull({ assignment_object: assignment, assignment_tasks: assignment_tasks }) + } + , [assignment, assignment_tasks]) + + if (assignmentError || assignmentTasksError) return
+ + if (!assignment || !assignment_tasks) return
+ + + return {children} +} + +export function useAssignments() { + return useContext(AssignmentContext) +} diff --git a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx b/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx index a631adcc..daeea79a 100644 --- a/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx @@ -3,6 +3,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { deleteActivity, updateActivity } from '@services/courses/activities' import { revalidateTags } from '@services/utils/ts/requests' import { + Backpack, Eye, File, FilePenLine, @@ -16,10 +17,12 @@ import { import { useLHSession } from '@components/Contexts/LHSessionContext' import Link from 'next/link' import { useRouter } from 'next/navigation' -import React from 'react' +import React, { useEffect, useState } from 'react' import { Draggable } from 'react-beautiful-dnd' import { mutate } from 'swr' -import { deleteAssignment, deleteAssignmentUsingActivityUUID } from '@services/courses/assignments' +import { deleteAssignment, deleteAssignmentUsingActivityUUID, getAssignmentFromActivityUUID } from '@services/courses/assignments' +import { useOrg } from '@components/Contexts/OrgContext' +import { useCourse } from '@components/Contexts/CourseContext' type ActivitiyElementProps = { orgslug: string @@ -47,7 +50,7 @@ function ActivityElement(props: ActivitiyElementProps) { async function deleteActivityUI() { // Assignments - if(props.activity.activity_type === 'TYPE_ASSIGNMENT') { + if (props.activity.activity_type === 'TYPE_ASSIGNMENT') { await deleteAssignmentUsingActivityUUID(props.activity.activity_uuid, access_token) } @@ -66,8 +69,6 @@ function ActivityElement(props: ActivitiyElementProps) { let modifiedActivityCopy = { name: modifiedActivity.activityName, description: '', - type: props.activity.type, - content: props.activity.content, } await updateActivity(modifiedActivityCopy, activityUUID, access_token) @@ -135,29 +136,7 @@ function ActivityElement(props: ActivitiyElementProps) { {/* Edit and View Button */}
- {props.activity.activity_type === 'TYPE_DYNAMIC' && ( - <> - -
- Edit Page -
- - - )} + {
)} + {props.activityType === 'TYPE_ASSIGNMENT' && ( + <> +
+
+ {' '} +
+
+ Assignment +
{' '} +
+ + )} {props.activityType === 'TYPE_DYNAMIC' && ( <>
@@ -240,4 +231,78 @@ const ActivityTypeIndicator = (props: { activityType: string }) => {
) } + +const ActivityElementOptions = ({ activity }: any) => { + const [assignmentUUID, setAssignmentUUID] = useState(''); + const org = useOrg() as any; + const course = useCourse() as any; + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + + async function getAssignmentUUIDFromActivityUUID(activityUUID: string) { + const activity = await getAssignmentFromActivityUUID(activityUUID, access_token); + if (activity) { + return activity.data.assignment_uuid; + } + } + + const fetchAssignmentUUID = async () => { + if (activity.activity_type === 'TYPE_ASSIGNMENT') { + const assignment_uuid = await getAssignmentUUIDFromActivityUUID(activity.activity_uuid); + setAssignmentUUID(assignment_uuid.replace('assignment_', '')); + } + }; + + useEffect(() => { + + console.log(activity) + + fetchAssignmentUUID(); + }, [activity, course]); + + return ( + <> + {activity.activity_type === 'TYPE_DYNAMIC' && ( + <> + +
+ Edit Page +
+ + + )} + {activity.activity_type === 'TYPE_ASSIGNMENT' && assignmentUUID && ( + <> + +
+ Edit Assignment +
+ + + )} + + ); +}; + export default ActivityElement diff --git a/apps/web/components/Dashboard/UI/BreadCrumbs.tsx b/apps/web/components/Dashboard/UI/BreadCrumbs.tsx index 05396e2b..f242f315 100644 --- a/apps/web/components/Dashboard/UI/BreadCrumbs.tsx +++ b/apps/web/components/Dashboard/UI/BreadCrumbs.tsx @@ -1,15 +1,16 @@ -import { useCourse } from '@components/Contexts/CourseContext' -import { Book, ChevronRight, School, User, Users } from 'lucide-react' +'use client'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { Backpack, Book, ChevronRight, School, User, Users } from 'lucide-react' import Link from 'next/link' import React from 'react' type BreadCrumbsProps = { - type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' + type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' last_breadcrumb?: string } function BreadCrumbs(props: BreadCrumbsProps) { - const course = useCourse() as any + const org = useOrg() as any return (
@@ -25,6 +26,15 @@ function BreadCrumbs(props: BreadCrumbsProps) { ) : ( '' )} + {props.type == 'assignments' ? ( +
+ {' '} + + Assignments +
+ ) : ( + '' + )} {props.type == 'user' ? (
{' '} @@ -64,7 +74,6 @@ function BreadCrumbs(props: BreadCrumbsProps) {
-
) } diff --git a/apps/web/components/Dashboard/UI/LeftMenu.tsx b/apps/web/components/Dashboard/UI/LeftMenu.tsx index 9f695bbc..bc2eff28 100644 --- a/apps/web/components/Dashboard/UI/LeftMenu.tsx +++ b/apps/web/components/Dashboard/UI/LeftMenu.tsx @@ -3,7 +3,7 @@ import { useOrg } from '@components/Contexts/OrgContext' import { signOut } from 'next-auth/react' import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import LearnHouseDashboardLogo from '@public/dashLogo.png' -import { BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react' +import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import React, { useEffect } from 'react' @@ -96,6 +96,14 @@ function LeftMenu() { + + + + + Date: Sat, 13 Jul 2024 20:03:08 +0200 Subject: [PATCH 07/25] feat: assignmentTask creation and switching --- apps/api/src/db/courses/assignments.py | 6 +- apps/api/src/routers/courses/assignments.py | 15 ++++ .../courses/activities/assignments.py | 46 +++++++++++ .../_components/Modals/NewTaskModal.tsx | 77 ++++++++++++++++++ .../_components/TaskEditor.tsx | 60 +++++++++----- .../[assignmentuuid]/_components/Tasks.tsx | 58 +++++++++++--- .../assignments/[assignmentuuid]/page.tsx | 55 ++++++++----- .../Assignments/AssignmentsTaskContext.tsx | 80 +++++++++++++++++++ apps/web/services/courses/assignments.ts | 12 +++ 9 files changed, 354 insertions(+), 55 deletions(-) create mode 100644 apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx create mode 100644 apps/web/components/Contexts/Assignments/AssignmentsTaskContext.tsx diff --git a/apps/api/src/db/courses/assignments.py b/apps/api/src/db/courses/assignments.py index fc288e36..a7dbc221 100644 --- a/apps/api/src/db/courses/assignments.py +++ b/apps/api/src/db/courses/assignments.py @@ -102,11 +102,7 @@ class AssignmentTaskBase(SQLModel): contents: Dict = Field(default={}, sa_column=Column(JSON)) max_grade_value: int = 0 # Value is always between 0-100 - assignment_id: int - org_id: int - course_id: int - chapter_id: int - activity_id: int + class AssignmentTaskCreate(AssignmentTaskBase): diff --git a/apps/api/src/routers/courses/assignments.py b/apps/api/src/routers/courses/assignments.py index f627d099..e238449c 100644 --- a/apps/api/src/routers/courses/assignments.py +++ b/apps/api/src/routers/courses/assignments.py @@ -24,6 +24,7 @@ from src.services.courses.activities.assignments import ( read_assignment, read_assignment_from_activity_uuid, read_assignment_submissions, + read_assignment_task, read_assignment_task_submissions, read_assignment_tasks, read_user_assignment_submissions, @@ -151,6 +152,20 @@ async def api_read_assignment_tasks( ) +@router.get("/task/{assignment_task_uuid}") +async def api_read_assignment_task( + request: Request, + assignment_task_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Read task for an assignment + """ + return await read_assignment_task( + request, assignment_task_uuid, current_user, db_session + ) + @router.put("/{assignment_uuid}/tasks/{task_uuid}") async def api_update_assignment_tasks( request: Request, diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index 46102cf7..6414a222 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -315,6 +315,10 @@ async def create_assignment_task( assignment_task.creation_date = str(datetime.now()) assignment_task.update_date = str(datetime.now()) assignment_task.org_id = course.org_id + assignment_task.chapter_id = assignment.chapter_id + assignment_task.activity_id = assignment.activity_id + assignment_task.assignment_id = assignment.id # type: ignore + assignment_task.course_id = assignment.course_id # Insert Assignment Task in DB db_session.add(assignment_task) @@ -365,6 +369,48 @@ async def read_assignment_tasks( for assignment_task in db_session.exec(statement).all() ] +async def read_assignment_task( + request: Request, + assignment_task_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Find assignment + statement = select(AssignmentTask).where(AssignmentTask.assignment_task_uuid == assignment_task_uuid) + assignmenttask = db_session.exec(statement).first() + + if not assignmenttask: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignmenttask.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment task read + return AssignmentTaskRead.model_validate(assignmenttask) + async def update_assignment_task( request: Request, diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx new file mode 100644 index 00000000..84a7e66f --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx @@ -0,0 +1,77 @@ +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { getAPIUrl } from '@services/config/config'; +import { createAssignmentTask } from '@services/courses/assignments' +import { AArrowUp, FileUp, ListTodo } from 'lucide-react' +import React from 'react' +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; + +function NewTaskModal({ closeModal, assignment_uuid }: any) { + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + const reminderShownRef = React.useRef(false); + + function showReminderToast() { + // Check if the reminder has already been shown using sessionStorage + if (sessionStorage.getItem("TasksReminderShown") !== "true") { + setTimeout(() => { + toast('When editing/adding your tasks, make sure to Unpublish your Assignment to avoid any issues with students, you can Publish it again when you are ready.', + { icon: '✋', duration: 10000, style: { minWidth: 600 } }); + // Mark the reminder as shown in sessionStorage + sessionStorage.setItem("TasksReminderShown", "true"); + }, 3000); + } + } + + async function createTask(type: string) { + const task_object = { + title: "Untitled Task", + description: "", + hint: "", + reference_file: "", + assignment_type: type, + contents: {}, + max_grade_value: 100, + } + await createAssignmentTask(task_object, assignment_uuid, access_token) + toast.success('Task created successfully') + showReminderToast() + mutate(`${getAPIUrl()}assignments/${assignment_uuid}/tasks`) + closeModal(false) + } + + + return ( +
+
createTask('QUIZ')} + className='flex flex-col space-y-2 justify-center text-center pt-10'> +
+ +
+

Quiz

+

Questions with multiple choice answers

+
+
createTask('FILE_SUBMISSION')} + className='flex flex-col space-y-2 justify-center text-center pt-10'> +
+ +
+

File submissions

+

Students can submit files for this task

+
+
toast.error('Forms are not yet supported')} + className='flex flex-col space-y-2 justify-center text-center pt-10 opacity-25'> +
+ +
+

Forms

+

Forms for students to fill out

+
+
+ ) +} + +export default NewTaskModal \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx index c76be5e6..c9e917b5 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx @@ -1,30 +1,52 @@ 'use client'; -import { Info, Link } from 'lucide-react' -import React from 'react' +import { useAssignmentsTask } from '@components/Contexts/Assignments/AssignmentsTaskContext'; +import { Info, TentTree } from 'lucide-react' +import React, { useEffect } from 'react' -function AssignmentTaskEditor({ task_uuid, page }: any) { +function AssignmentTaskEditor({ page }: any) { const [selectedSubPage, setSelectedSubPage] = React.useState(page) + const assignmentTaskState = useAssignmentsTask() as any + + useEffect(() => { + console.log(assignmentTaskState) + } + , [assignmentTaskState]) + return (
- -
-
- Assignment Test #1 -
-
-
-
- -
Overview
+ {assignmentTaskState.assignmentTask && Object.keys(assignmentTaskState.assignmentTask).length > 0 && ( +
+
+ Assignment Test #1 +
+
+
+
+ +
Overview
+
-
+ )} + {Object.keys(assignmentTaskState.assignmentTask).length == 0 && ( +
+
+
+ +
+ No Task Selected +
+
+
+
+ )} +
) } diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx index f2933817..1236cd45 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Tasks.tsx @@ -1,12 +1,20 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext' -import { Plus } from 'lucide-react'; +import Modal from '@components/StyledElements/Modal/Modal'; +import { FileUp, ListTodo, PanelLeftOpen, Plus } from 'lucide-react'; import React, { useEffect } from 'react' +import NewTaskModal from './Modals/NewTaskModal'; +import { useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext'; -function AssignmentTasks() { +function AssignmentTasks({ assignment_uuid }: any) { const assignments = useAssignments() as any; + const assignmentTaskHook = useAssignmentsTaskDispatch() as any; + const [isNewTaskModalOpen, setIsNewTaskModalOpen] = React.useState(false) + + async function setSelectTask(task_uuid: string) { + assignmentTaskHook({ type: 'setSelectedAssignmentTaskUUID', payload: task_uuid }) + } useEffect(() => { - console.log(assignments) }, [assignments]) @@ -15,19 +23,47 @@ function AssignmentTasks() {
{assignments && assignments?.assignment_tasks?.map((task: any) => { return ( -
-
-
{task.title}
+
setSelectTask(task.assignment_task_uuid)} + > +
+
+
+ {task.assignment_type === 'QUIZ' && } + {task.assignment_type === 'FILE_SUBMISSION' && } +
+
{task.title}
+
+
) })} -
- -

Add Task

-
+ + + } + dialogTitle="Add an Assignment Task" + dialogDescription="Create a new task for this assignment" + dialogTrigger={ +
+ +

Add Task

+
+ } + /> +
- +
) } diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx index cf7f609a..47f9793e 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/page.tsx @@ -1,12 +1,13 @@ 'use client'; import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' -import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' -import { BookOpen, BookOpenCheck, BookX, Check, Ellipsis, EllipsisVertical, GalleryVerticalEnd, Info, LayoutList, UserRoundCog } from 'lucide-react' +import { BookOpen, BookX, EllipsisVertical, LayoutList } from 'lucide-react' import React from 'react' import AssignmentTaskEditor from './_components/TaskEditor'; import { AssignmentProvider } from '@components/Contexts/Assignments/AssignmentContext'; import AssignmentTasks from './_components/Tasks'; import { useParams } from 'next/navigation'; +import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/AssignmentsTaskContext'; +import ToolTip from '@components/StyledElements/Tooltip/Tooltip'; function AssignmentEdit() { const params = useParams<{ assignmentuuid: string; }>() @@ -24,33 +25,47 @@ function AssignmentEdit() {
Published
-
- -

Publish

-
+ +
+ +

Publish

+
+
+

Unpublish

+
-
-
-
- -

Tasks

+
+ +
+
+ +

Tasks

+
+ + +
- - - -
-
- - - -
+
+ + + +
+
) diff --git a/apps/web/components/Contexts/Assignments/AssignmentsTaskContext.tsx b/apps/web/components/Contexts/Assignments/AssignmentsTaskContext.tsx new file mode 100644 index 00000000..64910a86 --- /dev/null +++ b/apps/web/components/Contexts/Assignments/AssignmentsTaskContext.tsx @@ -0,0 +1,80 @@ +'use client' +import React, { createContext, useContext, useEffect, useReducer } from 'react' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { getAssignmentTask } from '@services/courses/assignments' + +interface State { + selectedAssignmentTaskUUID: string | null; + assignmentTask: Record; +} + +interface Action { + type: string; + payload?: any; +} + +const initialState: State = { + selectedAssignmentTaskUUID: null, + assignmentTask: {} +}; + +export const AssignmentsTaskContext = createContext(undefined); +export const AssignmentsTaskDispatchContext = createContext | undefined>(undefined); + +export function AssignmentsTaskProvider({ children }: { children: React.ReactNode }) { + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + + const [state, dispatch] = useReducer(assignmentstaskReducer, initialState); + + async function fetchAssignmentTask(assignmentTaskUUID: string) { + const res = await getAssignmentTask(assignmentTaskUUID, access_token); + if (res.success) { + dispatch({ type: 'setAssignmentTask', payload: res }); + } + } + + useEffect(() => { + if (state.selectedAssignmentTaskUUID) { + fetchAssignmentTask(state.selectedAssignmentTaskUUID); + } + }, [state.selectedAssignmentTaskUUID]); + + return ( + + + {children} + + + ); +} + +export function useAssignmentsTask() { + const context = useContext(AssignmentsTaskContext); + if (context === undefined) { + throw new Error('useAssignmentsTask must be used within an AssignmentsTaskProvider'); + } + return context; +} + +export function useAssignmentsTaskDispatch() { + const context = useContext(AssignmentsTaskDispatchContext); + if (context === undefined) { + throw new Error('useAssignmentsTaskDispatch must be used within an AssignmentsTaskProvider'); + } + return context; +} + +function assignmentstaskReducer(state: State, action: Action): State { + switch (action.type) { + case 'setSelectedAssignmentTaskUUID': + return { ...state, selectedAssignmentTaskUUID: action.payload }; + case 'setAssignmentTask': + return { ...state, assignmentTask: action.payload }; + case 'reload': + return { ...state }; + default: + return state; + } +} + diff --git a/apps/web/services/courses/assignments.ts b/apps/web/services/courses/assignments.ts index f73f57ab..db88b95d 100644 --- a/apps/web/services/courses/assignments.ts +++ b/apps/web/services/courses/assignments.ts @@ -64,3 +64,15 @@ export async function createAssignmentTask( const res = await getResponseMetadata(result) return res } + +export async function getAssignmentTask( + assignmentTaskUUID: string, + access_token: string +) { + const result: any = await fetch( + `${getAPIUrl()}assignments/task/${assignmentTaskUUID}`, + RequestBodyWithAuthHeader('GET', null, null, access_token) + ) + const res = await getResponseMetadata(result) + return res +} From 3c41e0ee730b6db109c5525e907095f5e0fe88e1 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 14 Jul 2024 14:06:25 +0200 Subject: [PATCH 08/25] feat: edit tasks and general improvements --- apps/api/src/db/courses/assignments.py | 5 - apps/api/src/routers/courses/assignments.py | 32 +- .../courses/activities/assignments.py | 106 +++++- .../activities/uploads/tasks_ref_files.py | 24 ++ apps/api/src/services/utils/upload_content.py | 17 +- .../_components/Modals/NewTaskModal.tsx | 4 +- .../_components/TaskEditor.tsx | 313 +++++++++++++++++- .../[assignmentuuid]/_components/Tasks.tsx | 54 +-- .../assignments/[assignmentuuid]/page.tsx | 38 +-- .../Assignments/AssignmentsTaskContext.tsx | 19 +- .../components/StyledElements/Form/Form.tsx | 2 +- apps/web/services/courses/assignments.ts | 36 ++ apps/web/services/media/media.ts | 13 + 13 files changed, 570 insertions(+), 93 deletions(-) create mode 100644 apps/api/src/services/courses/activities/uploads/tasks_ref_files.py diff --git a/apps/api/src/db/courses/assignments.py b/apps/api/src/db/courses/assignments.py index a7dbc221..e5d38b9e 100644 --- a/apps/api/src/db/courses/assignments.py +++ b/apps/api/src/db/courses/assignments.py @@ -127,11 +127,6 @@ class AssignmentTaskUpdate(SQLModel): assignment_type: Optional[AssignmentTaskTypeEnum] contents: Optional[Dict] = Field(default={}, sa_column=Column(JSON)) max_grade_value: Optional[int] - assignment_id: Optional[int] - org_id: Optional[int] - course_id: Optional[int] - chapter_id: Optional[int] - activity_id: Optional[int] class AssignmentTask(AssignmentTaskBase, table=True): diff --git a/apps/api/src/routers/courses/assignments.py b/apps/api/src/routers/courses/assignments.py index e238449c..c9a9509f 100644 --- a/apps/api/src/routers/courses/assignments.py +++ b/apps/api/src/routers/courses/assignments.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, UploadFile from src.db.courses.assignments import ( AssignmentCreate, AssignmentRead, @@ -21,6 +21,7 @@ from src.services.courses.activities.assignments import ( delete_assignment_submission, delete_assignment_task, delete_assignment_task_submission, + put_assignment_task_reference_file, read_assignment, read_assignment_from_activity_uuid, read_assignment_submissions, @@ -64,6 +65,7 @@ async def api_read_assignment( """ return await read_assignment(request, assignment_uuid, current_user, db_session) + @router.get("/activity/{activity_uuid}") async def api_read_assignment_from_activity( request: Request, @@ -74,7 +76,9 @@ async def api_read_assignment_from_activity( """ Read an assignment """ - return await read_assignment_from_activity_uuid(request, activity_uuid, current_user, db_session) + return await read_assignment_from_activity_uuid( + request, activity_uuid, current_user, db_session + ) @router.put("/{assignment_uuid}") @@ -105,6 +109,7 @@ async def api_delete_assignment( """ return await delete_assignment(request, assignment_uuid, current_user, db_session) + @router.delete("/activity/{activity_uuid}") async def api_delete_assignment_from_activity( request: Request, @@ -115,7 +120,9 @@ async def api_delete_assignment_from_activity( """ Delete an assignment """ - return await delete_assignment_from_activity_uuid(request, activity_uuid, current_user, db_session) + return await delete_assignment_from_activity_uuid( + request, activity_uuid, current_user, db_session + ) ## ASSIGNMENTS Tasks ## @@ -166,7 +173,8 @@ async def api_read_assignment_task( request, assignment_task_uuid, current_user, db_session ) -@router.put("/{assignment_uuid}/tasks/{task_uuid}") + +@router.put("/{assignment_uuid}/tasks/{assignment_task_uuid}") async def api_update_assignment_tasks( request: Request, assignment_task_uuid: str, @@ -182,6 +190,22 @@ async def api_update_assignment_tasks( ) +@router.post("/{assignment_uuid}/tasks/{assignment_task_uuid}/ref_file") +async def api_put_assignment_task_ref_file( + request: Request, + assignment_task_uuid: str, + reference_file: UploadFile | None = None, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): + """ + Update tasks for an assignment + """ + return await put_assignment_task_reference_file( + request, db_session, assignment_task_uuid, current_user, reference_file + ) + + @router.delete("/{assignment_uuid}/tasks/{task_uuid}") async def api_delete_assignment_tasks( request: Request, diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index 6414a222..e5e3ef85 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Literal from uuid import uuid4 -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, UploadFile from sqlmodel import Session, select from src.db.courses.activities import Activity @@ -26,12 +26,16 @@ from src.db.courses.assignments import ( AssignmentUserSubmissionRead, ) from src.db.courses.courses import Course +from src.db.organizations import Organization from src.db.users import AnonymousUser, PublicUser from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) +from src.services.courses.activities.uploads.tasks_ref_files import ( + upload_reference_file, +) ## > Assignments CRUD @@ -104,6 +108,7 @@ async def read_assignment( # return assignment read return AssignmentRead.model_validate(assignment) + async def read_assignment_from_activity_uuid( request: Request, activity_uuid: str, @@ -119,7 +124,7 @@ async def read_assignment_from_activity_uuid( status_code=404, detail="Activity not found", ) - + # Check if course exists statement = select(Course).where(Course.id == activity.course_id) course = db_session.exec(statement).first() @@ -129,7 +134,7 @@ async def read_assignment_from_activity_uuid( status_code=404, detail="Course not found", ) - + # Check if assignment exists statement = select(Assignment).where(Assignment.activity_id == activity.id) assignment = db_session.exec(statement).first() @@ -227,6 +232,7 @@ async def delete_assignment( return {"message": "Assignment deleted"} + async def delete_assignment_from_activity_uuid( request: Request, activity_uuid: str, @@ -243,7 +249,7 @@ async def delete_assignment_from_activity_uuid( status_code=404, detail="Activity not found", ) - + # Check if course exists statement = select(Course).where(Course.id == activity.course_id) course = db_session.exec(statement).first() @@ -253,7 +259,7 @@ async def delete_assignment_from_activity_uuid( status_code=404, detail="Course not found", ) - + # Check if assignment exists statement = select(Assignment).where(Assignment.activity_id == activity.id) assignment = db_session.exec(statement).first() @@ -263,7 +269,7 @@ async def delete_assignment_from_activity_uuid( status_code=404, detail="Assignment not found", ) - + # RBAC check await rbac_check(request, course.course_uuid, current_user, "delete", db_session) @@ -317,7 +323,7 @@ async def create_assignment_task( assignment_task.org_id = course.org_id assignment_task.chapter_id = assignment.chapter_id assignment_task.activity_id = assignment.activity_id - assignment_task.assignment_id = assignment.id # type: ignore + assignment_task.assignment_id = assignment.id # type: ignore assignment_task.course_id = assignment.course_id # Insert Assignment Task in DB @@ -369,6 +375,7 @@ async def read_assignment_tasks( for assignment_task in db_session.exec(statement).all() ] + async def read_assignment_task( request: Request, assignment_task_uuid: str, @@ -376,7 +383,9 @@ async def read_assignment_task( db_session: Session, ): # Find assignment - statement = select(AssignmentTask).where(AssignmentTask.assignment_task_uuid == assignment_task_uuid) + statement = select(AssignmentTask).where( + AssignmentTask.assignment_task_uuid == assignment_task_uuid + ) assignmenttask = db_session.exec(statement).first() if not assignmenttask: @@ -384,7 +393,7 @@ async def read_assignment_task( status_code=404, detail="Assignment Task not found", ) - + # Check if assignment exists statement = select(Assignment).where(Assignment.id == assignmenttask.assignment_id) assignment = db_session.exec(statement).first() @@ -394,7 +403,57 @@ async def read_assignment_task( status_code=404, detail="Assignment not found", ) - + + # Check if course exists + statement = select(Course).where(Course.id == assignment.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # return assignment task read + return AssignmentTaskRead.model_validate(assignmenttask) + + +async def put_assignment_task_reference_file( + request: Request, + db_session: Session, + assignment_task_uuid: str, + current_user: PublicUser | AnonymousUser, + reference_file: UploadFile | None = None, +): + # Check if assignment task exists + statement = select(AssignmentTask).where( + AssignmentTask.assignment_task_uuid == assignment_task_uuid + ) + assignment_task = db_session.exec(statement).first() + + if not assignment_task: + raise HTTPException( + status_code=404, + detail="Assignment Task not found", + ) + + # Check if assignment exists + statement = select(Assignment).where(Assignment.id == assignment_task.assignment_id) + assignment = db_session.exec(statement).first() + + if not assignment: + raise HTTPException( + status_code=404, + detail="Assignment not found", + ) + + # Check for activity + statement = select(Activity).where(Activity.id == assignment.activity_id) + activity = db_session.exec(statement).first() + # Check if course exists statement = select(Course).where(Course.id == assignment.course_id) course = db_session.exec(statement).first() @@ -405,11 +464,34 @@ async def read_assignment_task( detail="Course not found", ) + # Get org uuid + org_statement = select(Organization).where(Organization.id == course.org_id) + org = db_session.exec(org_statement).first() + # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Upload reference file + if reference_file and reference_file.filename and activity and org: + name_in_disk = ( + f"{assignment_task_uuid}{uuid4()}.{reference_file.filename.split('.')[-1]}" + ) + await upload_reference_file( + reference_file, name_in_disk, activity.activity_uuid, org.org_uuid, course.course_uuid, assignment.assignment_uuid, assignment_task_uuid + ) + course.thumbnail_image = name_in_disk + # Update reference file + assignment_task.reference_file = name_in_disk + + assignment_task.update_date = str(datetime.now()) + + # Insert Assignment Task in DB + db_session.add(assignment_task) + db_session.commit() + db_session.refresh(assignment_task) # return assignment task read - return AssignmentTaskRead.model_validate(assignmenttask) + return AssignmentTaskRead.model_validate(assignment_task) async def update_assignment_task( diff --git a/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py b/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py new file mode 100644 index 00000000..d9f7c128 --- /dev/null +++ b/apps/api/src/services/courses/activities/uploads/tasks_ref_files.py @@ -0,0 +1,24 @@ +from uuid import uuid4 +from src.services.utils.upload_content import upload_content + + +async def upload_reference_file( + file, + name_in_disk, + activity_uuid, + org_uuid, + course_uuid, + assignment_uuid, + assignment_task_uuid, +): + contents = file.file.read() + file_format = file.filename.split(".")[-1] + + await upload_content( + f"courses/{course_uuid}/activities/{activity_uuid}/assignments/{assignment_uuid}/tasks/{assignment_task_uuid}", + "orgs", + org_uuid, + contents, + f"{name_in_disk}", + ["pdf", "docx", "mp4", "jpg", "jpeg", "png", "pptx"], + ) diff --git a/apps/api/src/services/utils/upload_content.py b/apps/api/src/services/utils/upload_content.py index 04448346..d32a787b 100644 --- a/apps/api/src/services/utils/upload_content.py +++ b/apps/api/src/services/utils/upload_content.py @@ -1,24 +1,37 @@ -from typing import Literal +from typing import Literal, Optional import boto3 from botocore.exceptions import ClientError import os +from fastapi import HTTPException + from config.config import get_learnhouse_config async def upload_content( directory: str, type_of_dir: Literal["orgs", "users"], - uuid: str, # org_uuid or user_uuid + uuid: str, # org_uuid or user_uuid file_binary: bytes, file_and_format: str, + allowed_formats: Optional[list[str]] = None, ): # Get Learnhouse Config learnhouse_config = get_learnhouse_config() + file_format = file_and_format.split(".")[-1].strip().lower() + # Get content delivery method content_delivery = learnhouse_config.hosting_config.content_delivery.type + # Check if format file is allowed + if allowed_formats: + if file_format not in allowed_formats: + raise HTTPException( + status_code=400, + detail=f"File format {file_format} not allowed", + ) + if content_delivery == "filesystem": # create folder for activity if not os.path.exists(f"content/{type_of_dir}/{uuid}/{directory}"): diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx index 84a7e66f..fea5b9f3 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/Modals/NewTaskModal.tsx @@ -58,7 +58,7 @@ function NewTaskModal({ closeModal, assignment_uuid }: any) {
-

File submissions

+

File submission

Students can submit files for this task

-

Forms

+

Form

Forms for students to fill out

diff --git a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx index c9e917b5..818bffd0 100644 --- a/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/assignments/[assignmentuuid]/_components/TaskEditor.tsx @@ -1,7 +1,17 @@ 'use client'; -import { useAssignmentsTask } from '@components/Contexts/Assignments/AssignmentsTaskContext'; -import { Info, TentTree } from 'lucide-react' -import React, { useEffect } from 'react' +import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; +import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form'; +import * as Form from '@radix-ui/react-form'; +import { getActivity } from '@services/courses/activities'; +import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments'; +import { getTaskRefFileDir } from '@services/media/media'; +import { useFormik } from 'formik'; +import { ArrowBigUpDash, Cloud, File, GalleryVerticalEnd, Info, Loader, TentTree, Upload, UploadCloud } from 'lucide-react' +import Link from 'next/link'; +import React, { use, useEffect } from 'react' +import toast from 'react-hot-toast'; function AssignmentTaskEditor({ page }: any) { const [selectedSubPage, setSelectedSubPage] = React.useState(page) @@ -15,23 +25,41 @@ function AssignmentTaskEditor({ page }: any) { return (
{assignmentTaskState.assignmentTask && Object.keys(assignmentTaskState.assignmentTask).length > 0 && ( -
-
- Assignment Test #1 -
-
-
-
- -
Overview
+
+
+
+ {assignmentTaskState?.assignmentTask.title} +
+
+
setSelectedSubPage('general')} + className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${selectedSubPage === 'general' + ? 'border-b-4' + : 'opacity-50' + } cursor-pointer`} + > +
+ +
General
+
+
+
setSelectedSubPage('content')} + className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${selectedSubPage === 'content' + ? 'border-b-4' + : 'opacity-50' + } cursor-pointer`} + > +
+ +
Content
+
+
+ {selectedSubPage === 'general' && } +
)} {Object.keys(assignmentTaskState.assignmentTask).length == 0 && ( @@ -51,4 +79,255 @@ function AssignmentTaskEditor({ page }: any) { ) } +function AssignmentTaskGeneralEdit() { + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + const assignmentTaskState = useAssignmentsTask() as any + const assignmentTaskStateHook = useAssignmentsTaskDispatch() as any + const assignment = useAssignments() as any + + const validate = (values: any) => { + const errors: any = {}; + if (values.max_grade_value < 20 || values.max_grade_value > 100) { + errors.max_grade_value = 'Value should be between 20 and 100'; + } + return errors; + }; + + + + const formik = useFormik({ + initialValues: { + title: assignmentTaskState.assignmentTask.title, + description: assignmentTaskState.assignmentTask.description, + hint: assignmentTaskState.assignmentTask.hint, + max_grade_value: assignmentTaskState.assignmentTask.max_grade_value, + }, + validate, + onSubmit: async values => { + const res = await updateAssignmentTask(values, assignmentTaskState.assignmentTask.assignment_task_uuid, assignment.assignment_object.assignment_uuid, access_token) + if (res) { + assignmentTaskStateHook({ type: 'reload' }) + } + else { + toast.error('Error updating task, please retry later.') + } + }, + enableReinitialize: true, + }) as any; + + return ( + + + + + + + + + + + + + + + + + + +