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 + }