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

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:
# ### 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 ###
@ -49,3 +51,4 @@ def downgrade() -> None:
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 ###

45
apps/api/poetry.lock generated
View file

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

View file

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

View file

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

View file

@ -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 = ""

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,153 @@
import React from 'react'
import FormLayout, {
ButtonBlack,
Flex,
FormField,
FormLabel,
FormMessage,
Input,
} 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()
}
function Assignment() {
return (
<div>Assignment</div>
<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
}