Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2b80730
new dashboard
GabrielFcGoncalves Mar 17, 2026
203b2ca
basic course backend implementation
GabrielFcGoncalves Mar 17, 2026
75b89e3
course edit frontend changes
GabrielFcGoncalves Mar 17, 2026
335790e
Merge branch 'dev' of github.com:PEI-SecureLearning/Core into SL-142-…
GabrielFcGoncalves Mar 21, 2026
3f7b63d
Merge branch 'dev' of github.com:PEI-SecureLearning/Core into SL-142-…
GabrielFcGoncalves Mar 21, 2026
2ed36f2
user progress is correctly working
GabrielFcGoncalves Mar 21, 2026
fafa52e
Frontend logic corrections
GabrielFcGoncalves Mar 21, 2026
fe01487
Frontend changes
GabrielFcGoncalves Mar 22, 2026
98202e2
Course calendarization
GabrielFcGoncalves Mar 22, 2026
b61cc3a
Changes to statistics
GabrielFcGoncalves Mar 22, 2026
059395f
Implement course expiry highlighting and unified campaign timeline
GabrielFcGoncalves Mar 22, 2026
397d655
Merge branch 'dev' of github.com:PEI-SecureLearning/Core into SL-142-…
GabrielFcGoncalves Mar 22, 2026
efdfddd
Working course refreshment
GabrielFcGoncalves Mar 23, 2026
a74108f
Frontend changes
GabrielFcGoncalves Mar 23, 2026
737c6d9
Campaign changes
GabrielFcGoncalves Mar 23, 2026
07064dd
Merge branch 'dev' of github.com:PEI-SecureLearning/Core into SL-142-…
GabrielFcGoncalves Mar 23, 2026
97fbc39
Frontend fix
GabrielFcGoncalves Mar 25, 2026
a1eb104
Unit test error
GabrielFcGoncalves Mar 25, 2026
f6c08e0
Unit test fix
GabrielFcGoncalves Mar 25, 2026
5e129ad
Coverage on new tests
GabrielFcGoncalves Mar 25, 2026
1a98338
More coverage
GabrielFcGoncalves Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion api/src/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

async def init_db():
SQLModel.metadata.create_all(engine)

#TODO CHECK THIS

Check warning on line 27 in api/src/core/db.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=PEI-SecureLearning_core&issues=AZ0kjmuRar6aFTbyUgjQ&open=AZ0kjmuRar6aFTbyUgjQ&pullRequest=107
with engine.connect() as conn:
try:
conn.execute(
Expand All @@ -39,6 +39,54 @@
"ADD COLUMN IF NOT EXISTS passing_score INTEGER NOT NULL DEFAULT 80"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS start_date TIMESTAMP WITHOUT TIME ZONE"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS deadline TIMESTAMP WITHOUT TIME ZONE"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS overdue BOOLEAN NOT NULL DEFAULT FALSE"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS cert_valid_days INTEGER NOT NULL DEFAULT 365"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS cert_expires_at TIMESTAMP WITHOUT TIME ZONE"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS expired BOOLEAN NOT NULL DEFAULT FALSE"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'SCHEDULED'"
)
)
conn.execute(
text(
"ALTER TABLE user_progress "
"ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP WITHOUT TIME ZONE"
)
)
conn.commit()
except Exception:
conn.rollback()
6 changes: 6 additions & 0 deletions api/src/core/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ def get_modules_collection() -> AsyncIOMotorCollection:
return db[settings.MONGODB_COLLECTION_MODULES]


def get_courses_collection() -> AsyncIOMotorCollection:
client = _get_client()
db = client[settings.MONGODB_DB]
return db[settings.MONGODB_COLLECTION_COURSES]


async def close_mongo_client() -> None:
"""Close the Mongo client when the app shuts down."""
global _client
Expand Down
1 change: 1 addition & 0 deletions api/src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Settings(BaseSettings):
MONGODB_COLLECTION_CONTENT: str = "content_pieces"
MONGODB_COLLECTION_CONTENT_FOLDERS: str = "content_folders"
MONGODB_COLLECTION_MODULES: str = "modules"
MONGODB_COLLECTION_COURSES: str = "courses"
MONGODB_INLINE_FILE_MAX_BYTES: int = 8 * 1024 * 1024

# Garage object storage (S3-compatible)
Expand Down
4 changes: 4 additions & 0 deletions api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
sending_profile,
modules,
phishing_kit,
courses,
progress,
)
from src.core.db import init_db
from src.core.mongo import close_mongo_client
Expand Down Expand Up @@ -81,3 +83,5 @@ async def health_check():
app.include_router(sending_profile.router, prefix="/api", tags=["sending-profiles"])
app.include_router(modules.router, prefix="/api", tags=["modules"])
app.include_router(phishing_kit.router, prefix="/api", tags=["phishing-kits"])
app.include_router(courses.router, prefix="/api", tags=["courses"])
app.include_router(progress.router, prefix="/api", tags=["progress"])
20 changes: 20 additions & 0 deletions api/src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
ComplianceQuizPayload,
CompliancePolicyResponse,
ComplianceQuizResponse,
CourseEnrollmentPayload,
)
from .module import (
Difficulty,
Expand All @@ -92,6 +93,15 @@
ModuleOut,
PaginatedModules,
)
from .course import (
CourseDifficulty,
CourseCreate,
CourseUpdate,
CoursePatch,
CourseOut,
PaginatedCourses,
)
from .user_progress import UserProgress, AssignmentStatus


__all__ = [
Expand Down Expand Up @@ -170,6 +180,7 @@
"ComplianceQuizPayload",
"CompliancePolicyResponse",
"ComplianceQuizResponse",
"CourseEnrollmentPayload",
# Learning Module
"Difficulty",
"QuestionType",
Expand All @@ -186,4 +197,13 @@
"ModulePatch",
"ModuleOut",
"PaginatedModules",
# Course
"CourseDifficulty",
"CourseCreate",
"CourseUpdate",
"CoursePatch",
"CourseOut",
"PaginatedCourses",
"UserProgress",
"AssignmentStatus",
]
17 changes: 17 additions & 0 deletions api/src/models/course/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from .document import (
CourseDifficulty,
CourseCreate,
CourseUpdate,
CoursePatch,
CourseOut,
PaginatedCourses,
)

__all__ = [
"CourseDifficulty",
"CourseCreate",
"CourseUpdate",
"CoursePatch",
"CourseOut",
"PaginatedCourses",
]
71 changes: 71 additions & 0 deletions api/src/models/course/document.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Pydantic models for the Course document stored in MongoDB.

Collection : courses
Primary key : _id (ObjectId, serialised as `id: str` in API responses)
"""

from __future__ import annotations

from datetime import datetime
from enum import StrEnum
from typing import Optional

from pydantic import BaseModel, Field


class CourseDifficulty(StrEnum):
EASY = "Easy"
MEDIUM = "Medium"
HARD = "Hard"


# ── Request models ────────────────────────────────────────────────────────────

class CourseCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., max_length=1000)
category: str = Field(..., min_length=1, max_length=100)
difficulty: CourseDifficulty
expected_time: str = Field(..., min_length=1, max_length=50)
cover_image: Optional[str] = Field(None, description="content_piece_id of the cover image")
modules: list[str] = Field(default_factory=list, description="Ordered list of module ObjectId hex strings")


class CourseUpdate(CourseCreate):
"""Full replacement — same shape as create."""


class CoursePatch(BaseModel):
"""Partial update — all fields optional."""
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
difficulty: Optional[CourseDifficulty] = None
expected_time: Optional[str] = None
cover_image: Optional[str] = None
modules: Optional[list[str]] = None


# ── Response model ────────────────────────────────────────────────────────────

class CourseOut(BaseModel):
id: str
title: str
description: str
category: str
difficulty: CourseDifficulty
expected_time: str
cover_image: Optional[str] = None # content_piece_id — frontend resolves presigned URL
modules: list[str] = Field(default_factory=list)
created_by: Optional[str] = None
created_at: datetime
updated_at: datetime


class PaginatedCourses(BaseModel):
items: list[CourseOut]
total: int
page: int
limit: int
pages: int
2 changes: 1 addition & 1 deletion api/src/models/email_sending/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class EmailSendingStatus(StrEnum):

class EmailSending(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: str = Field(foreign_key="useres.keycloak_id")
user_id: str = Field(foreign_key="users.keycloak_id")
scheduled_date: datetime
status: EmailSendingStatus = Field(default=EmailSendingStatus.SCHEDULED)
email_to: str
Expand Down
1 change: 1 addition & 0 deletions api/src/models/module/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class RichContentBlock(BaseModel):
order: int = Field(..., ge=0)
media_type: RichMediaType
url: str = ""
content_id: str = ""
caption: str = ""


Expand Down
2 changes: 2 additions & 0 deletions api/src/models/org_manager/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .schemas import (
OrgUserCreate,
OrgGroupCreate,
CourseEnrollmentPayload,
CompliancePolicyPayload,
QuizQuestionPayload,
ComplianceQuizPayload,
Expand All @@ -11,6 +12,7 @@
__all__ = [
"OrgUserCreate",
"OrgGroupCreate",
"CourseEnrollmentPayload",
"CompliancePolicyPayload",
"QuizQuestionPayload",
"ComplianceQuizPayload",
Expand Down
7 changes: 7 additions & 0 deletions api/src/models/org_manager/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
from pydantic import BaseModel


class CourseEnrollmentPayload(BaseModel):
course_ids: list[str]
start_date: datetime | None = None
deadline: datetime | None = None
cert_valid_days: float = 365.0


class OrgUserCreate(BaseModel):
username: str
name: str
Expand Down
2 changes: 1 addition & 1 deletion api/src/models/user/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class User(SQLModel, table=True):
__tablename__ = "useres"
__tablename__ = "users"
keycloak_id: str = Field(primary_key=True)
email: str
is_org_manager: bool = Field(default=False, nullable=False)
Expand Down
3 changes: 3 additions & 0 deletions api/src/models/user_progress/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .table import UserProgress, AssignmentStatus

__all__ = ["UserProgress", "AssignmentStatus"]
33 changes: 33 additions & 0 deletions api/src/models/user_progress/table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Optional
from datetime import datetime
from sqlmodel import SQLModel, Field, Column
from sqlalchemy import String, JSON
from sqlalchemy.dialects import postgresql
from enum import StrEnum

class AssignmentStatus(StrEnum):
SCHEDULED = "SCHEDULED"
ACTIVE = "ACTIVE"
OVERDUE = "OVERDUE"
COMPLETED = "COMPLETED"
RENEWAL_REQUIRED = "RENEWAL_REQUIRED"


class UserProgress(SQLModel, table=True):
__tablename__ = "user_progress"
user_id: str = Field(primary_key=True)
course_id: str = Field(primary_key=True)
progress_data: dict = Field(default={}, sa_column=Column(JSON().with_variant(postgresql.JSONB(), "postgresql")))
completed_sections: list[str] = Field(default=[], sa_column=Column(JSON().with_variant(postgresql.ARRAY(String), "postgresql")))
total_completed_tasks: int = Field(default=0)
is_certified: bool = Field(default=False)
start_date: datetime | None = Field(default=None)
deadline: datetime | None = Field(default=None)
cert_valid_days: float = Field(default=365.0)
cert_expires_at: datetime | None = Field(default=None)
overdue: bool = Field(default=False)
expired: bool = Field(default=False)
status: AssignmentStatus = Field(default=AssignmentStatus.SCHEDULED)
realm_name: Optional[str] = Field(default=None, index=True)
notified_at: Optional[datetime] = Field(default=None)
updated_at: datetime = Field(default_factory=datetime.utcnow)
Loading
Loading