feat: create and delete assignment activities from UI

This commit is contained in:
swve 2024-07-12 11:54:33 +02:00
parent 04c05e4f9a
commit 10e9be1d33
14 changed files with 358 additions and 14 deletions

View file

@ -1,7 +1,7 @@
import importlib import importlib
from logging.config import fileConfig from logging.config import fileConfig
import os import os
import alembic_postgresql_enum
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from sqlmodel import SQLModel from sqlmodel import SQLModel

View file

@ -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 ###

View file

@ -23,6 +23,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('activity', sa.Column('published', sa.Boolean(), nullable=False, server_default=sa.true())) 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', 'published_version')
op.drop_column('activity', '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.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.drop_constraint('trail_user_id_fkey', 'trail', type_='foreignkey')
op.create_foreign_key('trail_user_id_fkey', 'trail', 'user', ['user_id'], ['id'], ondelete='CASCADE') op.create_foreign_key('trail_user_id_fkey', 'trail', 'user', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ### # ### 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('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('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('published_version', sa.INTEGER(), autoincrement=False, nullable=False, server_default=sa.text('1')))
op.drop_column('activity', 'published') op.drop_column('activity', 'published')
# ### end Alembic commands ### # ### end Alembic commands ###

45
apps/api/poetry.lock generated
View file

@ -128,6 +128,21 @@ typing-extensions = ">=4"
[package.extras] [package.extras]
tz = ["backports.zoneinfo"] 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]] [[package]]
name = "anyio" name = "anyio"
version = "4.4.0" version = "4.4.0"
@ -3018,6 +3033,34 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"] pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"] 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]] [[package]]
name = "sqlmodel" name = "sqlmodel"
version = "0.0.19" version = "0.0.19"
@ -3871,4 +3914,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools",
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "76c4defc807fe83375766ac085982a2edf16e57b2d092a4494021f00a0424a4c" content-hash = "49d72c6871e3ecffae3b55ccad3a6b140f9a1ebbca84d7632dafd54e1d2b7f9d"

View file

@ -39,6 +39,8 @@ uvicorn = "0.30.1"
typer = "^0.12.3" typer = "^0.12.3"
chromadb = "^0.5.3" chromadb = "^0.5.3"
alembic = "^1.13.2" alembic = "^1.13.2"
alembic-postgresql-enum = "^1.2.0"
sqlalchemy-utils = "^0.41.2"
[build-system] [build-system]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View file

@ -5,8 +5,12 @@ from sqlmodel import Field, SQLModel
class CollectionCourse(SQLModel, table=True): class CollectionCourse(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
collection_id: int = Field(sa_column=Column(Integer, ForeignKey("collection.id", ondelete="CASCADE"))) collection_id: int = Field(
course_id: int = Field(sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))) 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") org_id: int = Field(default=None, foreign_key="organization.id")
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -16,6 +16,12 @@ class TrailBase(SQLModel):
class Trail(TrailBase, table=True): class Trail(TrailBase, table=True):
id: Optional[int] = Field(default=None, primary_key=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 = "" trail_uuid: str = ""
creation_date: str = "" creation_date: str = ""
update_date: str = "" update_date: str = ""

View file

@ -17,6 +17,7 @@ from src.services.courses.activities.assignments import (
create_assignment_task, create_assignment_task,
create_assignment_task_submission, create_assignment_task_submission,
delete_assignment, delete_assignment,
delete_assignment_from_activity_uuid,
delete_assignment_submission, delete_assignment_submission,
delete_assignment_task, delete_assignment_task,
delete_assignment_task_submission, delete_assignment_task_submission,
@ -90,6 +91,18 @@ async def api_delete_assignment(
""" """
return await delete_assignment(request, assignment_uuid, current_user, db_session) 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 ## ## ASSIGNMENTS Tasks ##

View file

@ -8,6 +8,7 @@ from uuid import uuid4
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.courses.activities import Activity
from src.db.courses.assignments import ( from src.db.courses.assignments import (
Assignment, Assignment,
AssignmentCreate, AssignmentCreate,
@ -184,6 +185,53 @@ async def delete_assignment(
return {"message": "Assignment deleted"} 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 ## > Assignments Tasks CRUD

View file

@ -19,6 +19,7 @@ import { useRouter } from 'next/navigation'
import React from 'react' import React from 'react'
import { Draggable } from 'react-beautiful-dnd' import { Draggable } from 'react-beautiful-dnd'
import { mutate } from 'swr' import { mutate } from 'swr'
import { deleteAssignment, deleteAssignmentUsingActivityUUID } from '@services/courses/assignments'
type ActivitiyElementProps = { type ActivitiyElementProps = {
orgslug: string orgslug: string
@ -45,6 +46,11 @@ function ActivityElement(props: ActivitiyElementProps) {
const activityUUID = props.activity.activity_uuid const activityUUID = props.activity.activity_uuid
async function deleteActivityUI() { 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) await deleteActivity(props.activity.activity_uuid, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`) mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
await revalidateTags(['courses'], props.orgslug) await revalidateTags(['courses'], props.orgslug)

View file

@ -102,9 +102,10 @@ function NewActivityModal({
{selectedView === 'assignments' && ( {selectedView === 'assignments' && (
<Assignment <Assignment
submitFileActivity={submitFileActivity} submitActivity={submitActivity}
chapterId={chapterId} chapterId={chapterId}
course={course} course={course}
closeModal={closeModal}
/>) />)
} }
</> </>

View file

@ -1,9 +1,153 @@
import React from 'react' import React from 'react'
import FormLayout, {
ButtonBlack,
Flex,
FormField,
FormLabel,
FormMessage,
Input,
function Assignment() { } from '@components/StyledElements/Form/Form'
return ( import * as Form from '@radix-ui/react-form'
<div>Assignment</div> 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 (
<FormLayout onSubmit={handleSubmit}>
<FormField name="assignment-activity-title">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Assignment Title</FormLabel>
<FormMessage match="valueMissing">
Please provide a name for your assignment
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleNameChange} type="text" required />
</Form.Control>
</FormField>
{/* Description */}
<FormField name="assignment-activity-description">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Assignment Description</FormLabel>
<FormMessage match="valueMissing">
Please provide a description for your assignment
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleDescriptionChange} type="text" required />
</Form.Control>
</FormField>
{/* Due date */}
<FormField name="assignment-activity-due-date">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Due Date</FormLabel>
<FormMessage match="valueMissing">
Please provide a due date for your assignment
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleDueDateChange} type="date" required />
</Form.Control>
</FormField>
{/* Grading type */}
<FormField name="assignment-activity-grading-type">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Grading Type</FormLabel>
<FormMessage match="valueMissing">
Please provide a grading type for your assignment
</FormMessage>
</Flex>
<Form.Control asChild>
<select className='bg-gray-100/40 rounded-lg px-1 py-2 outline outline-1 outline-gray-100' onChange={handleGradingTypeChange} required>
<option value="ALPHABET">Alphabet</option>
<option value="NUMERIC">Numeric</option>
<option value="PERCENTAGE">Percentage</option>
</select>
</Form.Control>
</FormField>
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
<Form.Submit asChild>
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
{isSubmitting ? (
<BarLoader
cssOverride={{ borderRadius: 60 }}
width={60}
color="#ffffff"
/>
) : (
'Create activity'
)}
</ButtonBlack>
</Form.Submit>
</Flex>
</FormLayout>
)
} }
export default Assignment export default NewAssignment

View file

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