Merge pull request #460 from learnhouse/fix/assignments-issues

Assignments issues
This commit is contained in:
Badr B. 2025-03-16 11:03:25 +01:00 committed by GitHub
commit 3a95abfa6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 489 additions and 163 deletions

View file

@ -1,4 +1,5 @@
import uvicorn
import logfire
from fastapi import FastAPI, Request
from config.config import LearnHouseConfig, get_learnhouse_config
from src.core.events.events import shutdown_app, startup_app
@ -38,6 +39,9 @@ app.add_middleware(
allow_headers=["*"],
)
logfire.configure(console=False, service_name=learnhouse_config.site_name,)
logfire.instrument_fastapi(app)
# Gzip Middleware (will add brotli later)
app.add_middleware(GZipMiddleware, minimum_size=1000)

View file

@ -5,12 +5,6 @@ from pydantic import BaseModel
from dotenv import load_dotenv
class SentryConfig(BaseModel):
dsn: str
environment: str
release: str
class CookieConfig(BaseModel):
domain: str
@ -53,7 +47,6 @@ class HostingConfig(BaseModel):
allowed_origins: list
allowed_regexp: str
self_hosted: bool
sentry_config: Optional[SentryConfig]
cookie_config: CookieConfig
content_delivery: ContentDeliveryConfig
@ -150,10 +143,7 @@ def get_learnhouse_config() -> LearnHouseConfig:
env_self_hosted = os.environ.get("LEARNHOUSE_SELF_HOSTED")
env_sql_connection_string = os.environ.get("LEARNHOUSE_SQL_CONNECTION_STRING")
# Sentry Config
env_sentry_dsn = os.environ.get("LEARNHOUSE_SENTRY_DSN")
env_sentry_environment = os.environ.get("LEARNHOUSE_SENTRY_ENVIRONMENT")
env_sentry_release = os.environ.get("LEARNHOUSE_SENTRY_RELEASE")
# Fill in values with YAML file if they are not provided
site_name = env_site_name or yaml_config.get("site_name")
@ -247,33 +237,6 @@ def get_learnhouse_config() -> LearnHouseConfig:
"mailing_config", {}
).get("system_email_adress")
# Sentry config
# check if the sentry config is provided in the YAML file
sentry_config_verif = (
yaml_config.get("hosting_config", {}).get("sentry_config")
or env_sentry_dsn
or env_sentry_environment
or env_sentry_release
or None
)
sentry_dsn = env_sentry_dsn or yaml_config.get("hosting_config", {}).get(
"sentry_config", {}
).get("dsn")
sentry_environment = env_sentry_environment or yaml_config.get(
"hosting_config", {}
).get("sentry_config", {}).get("environment")
sentry_release = env_sentry_release or yaml_config.get("hosting_config", {}).get(
"sentry_config", {}
).get("release")
if sentry_config_verif:
sentry_config = SentryConfig(
dsn=sentry_dsn, environment=sentry_environment, release=sentry_release
)
else:
sentry_config = None
# Payments config
env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY")
env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY")
@ -310,7 +273,6 @@ def get_learnhouse_config() -> LearnHouseConfig:
allowed_origins=list(allowed_origins),
allowed_regexp=allowed_regexp,
self_hosted=bool(self_hosted),
sentry_config=sentry_config,
cookie_config=cookie_config,
content_delivery=content_delivery,
)

View file

@ -27,7 +27,6 @@ dependencies = [
"redis>=5.0.7",
"requests>=2.32.3",
"resend>=2.4.0",
"sentry-sdk[fastapi]>=2.13.0",
"sqlmodel>=0.0.19",
"tiktoken>=0.7.0",
"uvicorn==0.30.1",
@ -38,6 +37,7 @@ dependencies = [
"sqlalchemy-utils>=0.41.2",
"stripe>=11.1.1",
"python-jose>=3.3.0",
"logfire[sqlalchemy]>=3.8.0",
]
[tool.ruff]

View file

@ -1,4 +1,5 @@
import logging
import logfire
import os
import importlib
from config.config import get_learnhouse_config
@ -39,6 +40,7 @@ engine = create_engine(
# Create all tables after importing all models
SQLModel.metadata.create_all(engine)
logfire.instrument_sqlalchemy(engine=engine)
async def connect_to_db(app: FastAPI):
app.db_engine = engine # type: ignore

View file

@ -5,7 +5,6 @@ from src.core.events.autoinstall import auto_install
from src.core.events.content import check_content_directory
from src.core.events.database import close_database, connect_to_db
from src.core.events.logs import create_logs_dir
from src.core.events.sentry import init_sentry
def startup_app(app: FastAPI) -> Callable:
@ -14,9 +13,6 @@ def startup_app(app: FastAPI) -> Callable:
learnhouse_config: LearnHouseConfig = get_learnhouse_config()
app.learnhouse_config = learnhouse_config # type: ignore
# Init Sentry
await init_sentry(app)
# Connect to database
await connect_to_db(app)

View file

@ -1,16 +0,0 @@
from fastapi import FastAPI
import sentry_sdk
from config.config import LearnHouseConfig
async def init_sentry(app: FastAPI) -> None:
learnhouse_config : LearnHouseConfig = app.learnhouse_config # type: ignore
if learnhouse_config.hosting_config.sentry_config is not None:
sentry_sdk.init(
dsn=app.learnhouse_config.hosting_config.sentry_config.dsn, # type: ignore
environment=app.learnhouse_config.hosting_config.sentry_config.environment, # type: ignore
release=app.learnhouse_config.hosting_config.sentry_config.release, # type: ignore
traces_sample_rate=1.0,
)

View file

@ -162,7 +162,7 @@ class AssignmentTask(AssignmentTaskBase, table=True):
class AssignmentTaskSubmissionBase(SQLModel):
"""Represents the common fields for an assignment task submission."""
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

View file

@ -37,6 +37,7 @@ from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
authorization_verify_based_on_roles,
)
from src.services.courses.activities.uploads.sub_file import upload_submission_file
from src.services.courses.activities.uploads.tasks_ref_files import (
@ -565,10 +566,17 @@ async def put_assignment_task_submission_file(
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, "update", db_session)
# RBAC check - only need read permission to submit files
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Upload reference file
# Check if user is enrolled in the course
if not await authorization_verify_based_on_roles(request, current_user.id, "read", course.course_uuid, db_session):
raise HTTPException(
status_code=403,
detail="You must be enrolled in this course to submit files"
)
# Upload submission file
if sub_file and sub_file.filename and activity and org:
name_in_disk = f"{assignment_task_uuid}_sub_{current_user.email}_{uuid4()}.{sub_file.filename.split('.')[-1]}"
await upload_submission_file(
@ -699,7 +707,7 @@ async def handle_assignment_task_submission(
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# TODO: Improve terrible implementation of this function
assignment_task_submission_uuid = assignment_task_submission_object.assignment_task_submission_uuid
# Check if assignment task exists
statement = select(AssignmentTask).where(
AssignmentTask.assignment_task_uuid == assignment_task_uuid
@ -722,15 +730,59 @@ async def handle_assignment_task_submission(
detail="Assignment not found",
)
# Check if user already submitted the assignment
statement = select(AssignmentTaskSubmission).where(
AssignmentTaskSubmission.assignment_task_id == assignment_task.id,
AssignmentTaskSubmission.user_id == current_user.id,
)
assignment_task_submission = db_session.exec(statement).first()
# Check if course exists
statement = select(Course).where(Course.id == assignment.course_id)
course = db_session.exec(statement).first()
# Update Task submission if it exists
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Check if user has instructor/admin permissions
is_instructor = await authorization_verify_based_on_roles(request, current_user.id, "update", course.course_uuid, db_session)
# For regular users, ensure they can only submit their own work
if not is_instructor:
# Check if user is enrolled in the course
if not await authorization_verify_based_on_roles(request, current_user.id, "read", course.course_uuid, db_session):
raise HTTPException(
status_code=403,
detail="You must be enrolled in this course to submit assignments"
)
# Regular users cannot update grades - only check if actual values are being set
if (assignment_task_submission_object.grade is not None and assignment_task_submission_object.grade != 0) or \
(assignment_task_submission_object.task_submission_grade_feedback is not None and assignment_task_submission_object.task_submission_grade_feedback != ""):
raise HTTPException(
status_code=403,
detail="You do not have permission to update grades"
)
# Only need read permission for submissions
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
else:
# Instructors/admins need update permission to grade
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Try to find existing submission if UUID is provided
assignment_task_submission = None
if assignment_task_submission_uuid:
statement = select(AssignmentTaskSubmission).where(
AssignmentTaskSubmission.assignment_task_submission_uuid == assignment_task_submission_uuid
)
assignment_task_submission = db_session.exec(statement).first()
# If submission exists, update it
if assignment_task_submission:
# For regular users, ensure they can only update their own submissions
if not is_instructor and assignment_task_submission.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="You can only update your own submissions"
)
# Update only the fields that were passed in
for var, value in vars(assignment_task_submission_object).items():
if value is not None:
@ -742,9 +794,6 @@ async def handle_assignment_task_submission(
db_session.commit()
db_session.refresh(assignment_task_submission)
# return assignment task submission read
return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission)
else:
# Create new Task submission
current_time = str(datetime.now())
@ -753,10 +802,10 @@ async def handle_assignment_task_submission(
model_data = assignment_task_submission_object.model_dump()
assignment_task_submission = AssignmentTaskSubmission(
assignment_task_submission_uuid=f"assignmenttasksubmission_{uuid4()}",
assignment_task_submission_uuid=assignment_task_submission_uuid or f"assignmenttasksubmission_{uuid4()}",
task_submission=model_data["task_submission"],
grade=model_data["grade"],
task_submission_grade_feedback=model_data["task_submission_grade_feedback"],
grade=0, # Always start with 0 for new submissions
task_submission_grade_feedback="", # Start with empty feedback
assignment_task_id=int(assignment_task.id), # type: ignore
assignment_type=assignment_task.assignment_type,
activity_id=assignment.activity_id,
@ -770,9 +819,10 @@ async def handle_assignment_task_submission(
# 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)
# return assignment task submission read
return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission)
async def read_user_assignment_task_submissions(
@ -1096,7 +1146,7 @@ async def create_assignment_submission(
)
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Create Assignment User Submission
assignment_user_submission = AssignmentUserSubmission(

88
apps/api/uv.lock generated
View file

@ -403,6 +403,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
]
[[package]]
name = "executing"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 },
]
[[package]]
name = "faker"
version = "36.1.1"
@ -1003,6 +1012,7 @@ dependencies = [
{ name = "langchain-community", version = "0.2.19", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12.4'" },
{ name = "langchain-openai", version = "0.1.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12.4'" },
{ name = "langchain-openai", version = "0.1.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12.4'" },
{ name = "logfire", extra = ["sqlalchemy"] },
{ name = "openai" },
{ name = "passlib" },
{ name = "psycopg2-binary" },
@ -1015,7 +1025,6 @@ dependencies = [
{ name = "redis" },
{ name = "requests" },
{ name = "resend" },
{ name = "sentry-sdk", extra = ["fastapi"] },
{ name = "sqlalchemy-utils" },
{ name = "sqlmodel" },
{ name = "stripe" },
@ -1038,6 +1047,7 @@ requires-dist = [
{ name = "langchain", specifier = ">=0.1.7" },
{ name = "langchain-community", specifier = ">=0.0.20" },
{ name = "langchain-openai", specifier = ">=0.0.6" },
{ name = "logfire", extras = ["sqlalchemy"], specifier = ">=3.8.0" },
{ name = "openai", specifier = ">=1.50.2" },
{ name = "passlib", specifier = ">=1.7.4" },
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
@ -1050,7 +1060,6 @@ requires-dist = [
{ name = "redis", specifier = ">=5.0.7" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "resend", specifier = ">=2.4.0" },
{ name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.13.0" },
{ name = "sqlalchemy-utils", specifier = ">=0.41.2" },
{ name = "sqlmodel", specifier = ">=0.0.19" },
{ name = "stripe", specifier = ">=11.1.1" },
@ -1059,6 +1068,29 @@ requires-dist = [
{ name = "uvicorn", specifier = "==0.30.1" },
]
[[package]]
name = "logfire"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "executing" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-sdk" },
{ name = "protobuf" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/7c/ccd2aa47da9154788f0846864481b194695ca38db486500ae207b2c9f995/logfire-3.8.0.tar.gz", hash = "sha256:dc64745641d077e9411836c0d17af08f4fcef2737545dc43b67e51b20679e88b", size = 292729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/d2/5644fab9b42c9f6de2dfe202666e125349d08d963d2f788aa44780d4da71/logfire-3.8.0-py3-none-any.whl", hash = "sha256:a2cda4d7bfb3a3a21bf99aa5c94bde6edff405ffa312b4dcbf8b99a8d52bf32e", size = 186976 },
]
[package.optional-dependencies]
sqlalchemy = [
{ name = "opentelemetry-instrumentation-sqlalchemy" },
]
[[package]]
name = "mako"
version = "1.3.9"
@ -1304,6 +1336,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/35/d9f63fd84c2ed8dbd407bcbb933db4ed6e1b08e7fbdaca080b9ac309b927/opentelemetry_exporter_otlp_proto_grpc-1.30.0-py3-none-any.whl", hash = "sha256:2906bcae3d80acc54fd1ffcb9e44d324e8631058b502ebe4643ca71d1ff30830", size = 18550 },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/f9/abb9191d536e6a2e2b7903f8053bf859a76bf784e3ca19a5749550ef19e4/opentelemetry_exporter_otlp_proto_http-1.30.0.tar.gz", hash = "sha256:c3ae75d4181b1e34a60662a6814d0b94dd33b628bee5588a878bed92cee6abdc", size = 15073 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/3c/cdf34bc459613f2275aff9b258f35acdc4c4938dad161d17437de5d4c034/opentelemetry_exporter_otlp_proto_http-1.30.0-py3-none-any.whl", hash = "sha256:9578e790e579931c5ffd50f1e6975cbdefb6a0a0a5dea127a6ae87df10e0a589", size = 17245 },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.51b0"
@ -1351,6 +1401,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117 },
]
[[package]]
name = "opentelemetry-instrumentation-sqlalchemy"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/b2/970b1b46576b663bba64503486afe266c064c2bfd1862876420714ce29d9/opentelemetry_instrumentation_sqlalchemy-0.51b0.tar.gz", hash = "sha256:dbfe95b69006017f903dda194606be458d54789e6b3419d37161fb8861bb98a5", size = 14582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/d4/b68c3b3388dd5107f3ed532747e112249c152ba44af71a1f96673d66e3ee/opentelemetry_instrumentation_sqlalchemy-0.51b0-py3-none-any.whl", hash = "sha256:5ff4816228b496aef1511149e2b17a25e0faacec4d5eb65bf18a9964af40f1af", size = 14132 },
]
[[package]]
name = "opentelemetry-proto"
version = "1.30.0"
@ -1816,24 +1882,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/ac/e7dc469e49048dc57f62e0c555d2ee3117fa30813d2a1a2962cce3a2a82a/s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc", size = 84151 },
]
[[package]]
name = "sentry-sdk"
version = "2.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/b6/662988ecd2345bf6c3a5c306a9a3590852742eff91d0a78a143398b816f3/sentry_sdk-2.22.0.tar.gz", hash = "sha256:b4bf43bb38f547c84b2eadcefbe389b36ef75f3f38253d7a74d6b928c07ae944", size = 303539 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/7f/0e4459173e9671ba5f75a48dda2442bcc48a12c79e54e5789381c8c6a9bc/sentry_sdk-2.22.0-py2.py3-none-any.whl", hash = "sha256:3d791d631a6c97aad4da7074081a57073126c69487560c6f8bffcf586461de66", size = 325815 },
]
[package.optional-dependencies]
fastapi = [
{ name = "fastapi" },
]
[[package]]
name = "shellingham"
version = "1.5.4"

View file

@ -12,6 +12,7 @@ import toast from 'react-hot-toast';
type FileSchema = {
fileUUID: string;
assignment_task_submission_uuid?: string;
};
type TaskFileObjectProps = {
@ -64,13 +65,13 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
// wait for 1 second to show loading animation
await new Promise((r) => setTimeout(r, 1500))
if (res.success === false) {
setError(res.data.detail)
setIsLoading(false)
} else {
assignmentTaskStateHook({ type: 'reload' })
setUserSubmissions({
fileUUID: res.data.file_uuid,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
})
setIsLoading(false)
setError('')
@ -86,8 +87,14 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
if (assignmentTaskUUID) {
const res = await getAssignmentTaskSubmissionsMe(assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
setUserSubmissions(res.data.task_submission);
setInitialUserSubmissions(res.data.task_submission);
setUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
setInitialUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
}
}
}
@ -101,6 +108,7 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
// Save the quiz to the server
const values = {
assignment_task_submission_uuid: userSubmissions.assignment_task_submission_uuid,
task_submission: userSubmissions,
grade: 0,
task_submission_grade_feedback: '',
@ -156,9 +164,15 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
if (assignmentTaskUUID && user_id) {
const res = await getAssignmentTaskSubmissionsUser(assignmentTaskUUID, user_id, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
setUserSubmissions(res.data.task_submission);
setUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
setUserSubmissionObject(res.data);
setInitialUserSubmissions(res.data.task_submission);
setInitialUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
}
}
}
@ -173,6 +187,7 @@ export default function TaskFileObject({ view, user_id, assignmentTaskUUID }: Ta
// Save the grade to the server
const values = {
assignment_task_submission_uuid: userSubmissions.assignment_task_submission_uuid,
task_submission: userSubmissions,
grade: grade,
task_submission_grade_feedback: 'Graded by teacher : @' + session.data.user.username,

View file

@ -27,6 +27,7 @@ type QuizSubmitSchema = {
optionUUID: string;
answer: boolean
}[];
assignment_task_submission_uuid?: string;
};
type TaskQuizObjectProps = {
@ -175,8 +176,14 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
if (assignmentTaskUUID) {
const res = await getAssignmentTaskSubmissionsMe(assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
setUserSubmissions(res.data.task_submission);
setInitialUserSubmissions(res.data.task_submission);
setUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
setInitialUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
}
}
@ -242,9 +249,15 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
if (assignmentTaskUUID && user_id) {
const res = await getAssignmentTaskSubmissionsUser(assignmentTaskUUID, user_id, assignment.assignment_object.assignment_uuid, access_token);
if (res.success) {
setUserSubmissions(res.data.task_submission);
setUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
setUserSubmissionObject(res.data);
setInitialUserSubmissions(res.data.task_submission);
setInitialUserSubmissions({
...res.data.task_submission,
assignment_task_submission_uuid: res.data.assignment_task_submission_uuid
});
}
}
@ -271,6 +284,7 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
// Save the grade to the server
const values = {
assignment_task_submission_uuid: userSubmissions.assignment_task_submission_uuid,
task_submission: userSubmissions,
grade: finalGrade,
task_submission_grade_feedback: 'Auto graded by system',

View file

@ -1,6 +1,6 @@
'use client';
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, UserRoundPen } from 'lucide-react'
import { ArrowRight, BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, Pencil, UserRoundPen } from 'lucide-react'
import React, { useEffect } from 'react'
import { AssignmentProvider, useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip';
@ -16,6 +16,7 @@ import { updateActivity } from '@services/courses/activities';
import dynamic from 'next/dynamic';
import AssignmentEditorSubPage from './subpages/AssignmentEditorSubPage';
import { useMediaQuery } from 'usehooks-ts';
import EditAssignmentModal from '@components/Objects/Modals/Activities/Assignments/EditAssignmentModal';
const AssignmentSubmissionsSubPage = dynamic(() => import('./subpages/AssignmentSubmissionsSubPage'))
function AssignmentEdit() {
@ -46,7 +47,9 @@ function AssignmentEdit() {
<div className="pl-10 mr-10 tracking-tighter">
<BrdCmpx />
<div className="w-100 flex justify-between">
<div className="flex font-bold text-2xl">Assignment Tools </div>
<div className="flex font-bold text-2xl">
<AssignmentTitle />
</div>
</div>
</div>
<div className='flex flex-col justify-center antialiased'>
@ -106,6 +109,7 @@ function PublishingState() {
const assignment = useAssignments() as any;
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
async function updateAssignmentPublishState(assignmentUUID: string) {
const res = await updateAssignment({ published: !assignment?.assignment_object?.published }, assignmentUUID, access_token)
@ -125,50 +129,83 @@ function PublishingState() {
}, [assignment])
return (
<div className='flex mx-auto mt-5 items-center space-x-4'>
<div className={`flex text-xs rounded-full px-3.5 py-2 mx-auto font-bold outline outline-1 ${!assignment?.assignment_object?.published ? 'outline-gray-300 bg-gray-200/60' : 'outline-green-300 bg-green-200/60'}`}>
{assignment?.assignment_object?.published ? 'Published' : 'Unpublished'}
</div>
<div><EllipsisVertical className='text-gray-500' size={13} /></div>
<ToolTip
side='left'
slateBlack
sideOffset={10}
content="Preview the Assignment as a student" >
<Link
target='_blank'
href={`/course/${assignment?.course_object?.course_uuid.replace('course_', '')}/activity/${assignment?.activity_object?.activity_uuid.replace('activity_', '')}`}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-cyan-800 font-medium from-sky-400/50 to-cyan-200/80 border border-cyan-600/10 shadow-cyan-900/10 shadow-lg'>
<Eye size={18} />
<p className=' text-sm font-bold'>Preview</p>
</Link>
</ToolTip>
{assignment?.assignment_object?.published && <ToolTip
side='left'
slateBlack
sideOffset={10}
content="Make your Assignment unavailable for students" >
<div
onClick={() => updateAssignmentPublishState(assignment?.assignment_object?.assignment_uuid)}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-gray-800 font-medium from-gray-400/50 to-gray-200/80 border border-gray-600/10 shadow-gray-900/10 shadow-lg'>
<BookX size={18} />
<p className='text-sm font-bold'>Unpublish</p>
<>
<div className='flex mx-auto mt-5 items-center space-x-4'>
<div className={`flex text-xs rounded-full px-3.5 py-2 mx-auto font-bold outline outline-1 ${!assignment?.assignment_object?.published ? 'outline-gray-300 bg-gray-200/60' : 'outline-green-300 bg-green-200/60'}`}>
{assignment?.assignment_object?.published ? 'Published' : 'Unpublished'}
</div>
</ToolTip>}
{!assignment?.assignment_object?.published &&
<div><EllipsisVertical className='text-gray-500' size={13} /></div>
<ToolTip
side='left'
slateBlack
sideOffset={10}
content="Make your Assignment public and available for students" >
content="Edit Assignment Details">
<div
onClick={() => setIsEditModalOpen(true)}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-blue-800 font-medium from-blue-400/50 to-blue-200/80 border border-blue-600/10 shadow-blue-900/10 shadow-lg'>
<Pencil size={18} />
<p className='text-sm font-bold'>Edit</p>
</div>
</ToolTip>
<ToolTip
side='left'
slateBlack
sideOffset={10}
content="Preview the Assignment as a student" >
<Link
target='_blank'
href={`/course/${assignment?.course_object?.course_uuid.replace('course_', '')}/activity/${assignment?.activity_object?.activity_uuid.replace('activity_', '')}`}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-cyan-800 font-medium from-sky-400/50 to-cyan-200/80 border border-cyan-600/10 shadow-cyan-900/10 shadow-lg'>
<Eye size={18} />
<p className=' text-sm font-bold'>Preview</p>
</Link>
</ToolTip>
{assignment?.assignment_object?.published && <ToolTip
side='left'
slateBlack
sideOffset={10}
content="Make your Assignment unavailable for students" >
<div
onClick={() => updateAssignmentPublishState(assignment?.assignment_object?.assignment_uuid)}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-green-800 font-medium from-green-400/50 to-lime-200/80 border border-green-600/10 shadow-green-900/10 shadow-lg'>
<BookOpen size={18} />
<p className=' text-sm font-bold'>Publish</p>
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-gray-800 font-medium from-gray-400/50 to-gray-200/80 border border-gray-600/10 shadow-gray-900/10 shadow-lg'>
<BookX size={18} />
<p className='text-sm font-bold'>Unpublish</p>
</div>
</ToolTip>}
</div>
{!assignment?.assignment_object?.published &&
<ToolTip
side='left'
slateBlack
sideOffset={10}
content="Make your Assignment public and available for students" >
<div
onClick={() => updateAssignmentPublishState(assignment?.assignment_object?.assignment_uuid)}
className='flex px-3 py-2 cursor-pointer rounded-md space-x-2 items-center bg-linear-to-bl text-green-800 font-medium from-green-400/50 to-lime-200/80 border border-green-600/10 shadow-green-900/10 shadow-lg'>
<BookOpen size={18} />
<p className=' text-sm font-bold'>Publish</p>
</div>
</ToolTip>}
</div>
{isEditModalOpen && (
<EditAssignmentModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
assignment={assignment?.assignment_object}
accessToken={access_token}
/>
)}
</>
)
}
function AssignmentTitle() {
const assignment = useAssignments() as any;
return (
<div className="flex items-center gap-2">
Assignment Tools
</div>
);
}

View file

@ -41,21 +41,27 @@ function AssignmentSubmissionsSubPage({ assignment_uuid }: { assignment_uuid: st
<X size={18} />
<h3>Late</h3>
</div>
{renderSubmissions('LATE')}
<div className='flex flex-col gap-4'>
{renderSubmissions('LATE')}
</div>
</div>
<div className='flex-1'>
<div className='flex w-fit mx-auto px-3.5 py-1 bg-amber-600/80 space-x-2 my-5 items-center text-sm font-bold text-white rounded-full'>
<SendHorizonal size={18} />
<h3>Submitted</h3>
</div>
{renderSubmissions('SUBMITTED')}
<div className='flex flex-col gap-4'>
{renderSubmissions('SUBMITTED')}
</div>
</div>
<div className='flex-1'>
<div className='flex w-fit mx-auto px-3.5 py-1 bg-emerald-600/80 space-x-2 my-5 items-center text-sm font-bold text-white rounded-full'>
<UserCheck size={18} />
<h3>Graded</h3>
</div>
{renderSubmissions('GRADED')}
<div className='flex flex-col gap-4'>
{renderSubmissions('GRADED')}
</div>
</div>
</div>

View file

@ -15,7 +15,6 @@ function EvaluateAssignment({ user_id }: any) {
const assignments = useAssignments() as any;
const session = useLHSession() as any;
const org = useOrg() as any;
const router = useRouter();
async function gradeAssignment() {
const res = await putFinalGrade(user_id, assignments?.assignment_object.assignment_uuid, session.data?.tokens?.access_token);
@ -92,17 +91,17 @@ function EvaluateAssignment({ user_id }: any) {
)
})}
<div className='flex space-x-4 font-semibold items-center justify-between'>
<button onClick={rejectAssignment} className='flex space-x-2 px-4 py-2 text-sm bg-rose-600/80 text-white rounded-lg nice-shadow items-center'>
<button onClick={rejectAssignment} className='flex space-x-2 px-4 py-2 text-sm bg-rose-600/80 text-white rounded-lg nice-shadow items-center cursor-pointer'>
<X size={18} />
<span>Reject Assignment</span>
</button>
<div className='flex space-x-3 items-center'>
<button onClick={gradeAssignment} className='flex space-x-2 px-4 py-2 text-sm bg-violet-600/80 text-white rounded-lg nice-shadow items-center'>
<button onClick={gradeAssignment} className='flex space-x-2 px-4 py-2 text-sm bg-violet-600/80 text-white rounded-lg nice-shadow items-center cursor-pointer'>
<BookOpenCheck size={18} />
<span>Set final grade</span>
</button>
<MoveRight className='text-gray-400' size={18} />
<button onClick={markActivityAsDone} className='flex space-x-2 px-4 py-2 text-sm bg-teal-600/80 text-white rounded-lg nice-shadow items-center'>
<button onClick={markActivityAsDone} className='flex space-x-2 px-4 py-2 text-sm bg-teal-600/80 text-white rounded-lg nice-shadow items-center cursor-pointer'>
<Check size={18} />
<span>Mark Activity as Done for User</span>
</button>

View file

@ -40,7 +40,22 @@ function AssignmentStudentActivity() {
</div>
</div>
<div className='w-full rounded-full bg-slate-500/5 nice-shadow h-[2px]'></div>
{assignments?.assignment_object?.description && (
<div className='flex flex-col space-y-2 p-4 md:p-6 bg-slate-100/30 rounded-md nice-shadow'>
<div className='flex flex-col space-y-3'>
<div className='flex items-center gap-2 text-slate-700'>
<Info size={16} className="text-slate-500" />
<h3 className='text-sm font-semibold'>Assignment Description</h3>
</div>
<div className='pl-6'>
<p className='text-sm leading-relaxed text-slate-600'>{assignments.assignment_object.description}</p>
</div>
</div>
</div>
)}
{assignments && assignments?.assignment_tasks?.sort((a: any, b: any) => a.id - b.id).map((task: any, index: number) => {
return (

View file

@ -0,0 +1,194 @@
import React from 'react';
import { updateAssignment } from '@services/courses/assignments';
import { mutate } from 'swr';
import { getAPIUrl } from '@services/config/config';
import toast from 'react-hot-toast';
import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
Textarea,
Flex,
FormLabel,
FormMessage
} from '@components/Objects/StyledElements/Form/Form';
import * as Form from '@radix-ui/react-form';
import { useFormik } from 'formik';
import Modal from '@components/Objects/StyledElements/Modal/Modal';
interface Assignment {
assignment_uuid: string;
title: string;
description: string;
due_date?: string;
grading_type?: 'ALPHABET' | 'NUMERIC' | 'PERCENTAGE';
}
interface EditAssignmentFormProps {
onClose: () => void;
assignment: Assignment;
accessToken: string;
}
interface EditAssignmentModalProps {
isOpen: boolean;
onClose: () => void;
assignment: Assignment;
accessToken: string;
}
const EditAssignmentForm: React.FC<EditAssignmentFormProps> = ({
onClose,
assignment,
accessToken
}) => {
const formik = useFormik({
initialValues: {
title: assignment.title || '',
description: assignment.description || '',
due_date: assignment.due_date || '',
grading_type: assignment.grading_type || 'ALPHABET'
},
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
const toast_loading = toast.loading('Updating assignment...');
try {
const res = await updateAssignment(values, assignment.assignment_uuid, accessToken);
if (res.success) {
mutate(`${getAPIUrl()}assignments/${assignment.assignment_uuid}`);
toast.success('Assignment updated successfully');
onClose();
} else {
toast.error('Failed to update assignment');
}
} catch (error) {
toast.error('An error occurred while updating the assignment');
} finally {
toast.dismiss(toast_loading);
setSubmitting(false);
}
}
});
return (
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="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={formik.handleChange}
value={formik.values.title}
type="text"
required
/>
</Form.Control>
</FormField>
<FormField name="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>
<Textarea
onChange={formik.handleChange}
value={formik.values.description}
required
/>
</Form.Control>
</FormField>
<FormField name="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
type="date"
onChange={formik.handleChange}
value={formik.values.due_date}
required
/>
</Form.Control>
</FormField>
<FormField name="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>
<select
id="grading_type"
name="grading_type"
className='w-full bg-gray-100/40 rounded-lg px-3 py-2 outline outline-1 outline-gray-100'
onChange={(e) => formik.setFieldValue('grading_type', e.target.value, true)}
value={formik.values.grading_type}
required
>
<option value="ALPHABET">Alphabet</option>
<option value="NUMERIC">Numeric</option>
<option value="PERCENTAGE">Percentage</option>
</select>
</FormField>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md"
>
Cancel
</button>
<Form.Submit asChild>
<button
type="submit"
disabled={formik.isSubmitting}
className="px-4 py-2 bg-black text-white font-bold rounded-md hover:bg-black/90"
>
{formik.isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
</div>
</FormLayout>
);
};
const EditAssignmentModal: React.FC<EditAssignmentModalProps> = ({
isOpen,
onClose,
assignment,
accessToken
}) => {
return (
<Modal
isDialogOpen={isOpen}
onOpenChange={onClose}
minHeight="md"
minWidth="lg"
dialogContent={
<EditAssignmentForm
onClose={onClose}
assignment={assignment}
accessToken={accessToken}
/>
}
dialogTitle="Edit Assignment"
dialogDescription="Update assignment details"
dialogTrigger={null}
/>
);
};
export default EditAssignmentModal;

View file

@ -38,7 +38,7 @@ const ConfirmationModal = (params: ModalParams) => {
<Dialog.Portal>
<DialogOverlay />
<DialogContent>
<div className="h-26 flex space-x-4 tracking-tight">
<div className="flex space-x-4 tracking-tight">
<div
className={`icon p-6 rounded-xl flex items-center align-content-center ${
params.status === 'warning' ? warningColors : infoColors
@ -51,13 +51,13 @@ const ConfirmationModal = (params: ModalParams) => {
)}
</div>
<div className="text pt-1 space-x-0 w-auto grow">
<div className="text-xl font-bold text-black ">
<div className="text-xl font-bold text-black">
{params.dialogTitle}
</div>
<div className="text-md text-gray-500 w-60 leading-tight">
<div className="text-md text-gray-500 leading-tight mt-1">
{params.confirmationMessage}
</div>
<div className="flex flex-row-reverse pt-2">
<div className="flex flex-row-reverse mt-4">
<div
id={params.buttonid}
className={`rounded-md text-sm px-3 py-2 font-bold flex justify-center items-center hover:cursor-pointer ${
@ -126,11 +126,11 @@ const DialogContent = styled(Dialog.Content, {
transform: 'translate(-50%, -50%)',
width: 'auto',
minWidth: '500px',
overflow: 'hidden',
maxWidth: '600px',
overflow: 'visible',
height: 'auto',
maxHeight: '85vh',
maxWidth: '600px',
padding: 11,
padding: '24px',
animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
'&:focus': { outline: 'none' },