diff --git a/apps/api/config/config.py b/apps/api/config/config.py index 0e1905b4..30d579a8 100644 --- a/apps/api/config/config.py +++ b/apps/api/config/config.py @@ -46,7 +46,8 @@ class HostingConfig(BaseModel): class DatabaseConfig(BaseModel): - mongodb_connection_string: Optional[str] + sql_connection_string: Optional[str] + mongo_connection_string: Optional[str] class LearnHouseConfig(BaseModel): @@ -105,9 +106,7 @@ def get_learnhouse_config() -> LearnHouseConfig: env_allowed_origins = env_allowed_origins.split(",") env_allowed_regexp = os.environ.get("LEARNHOUSE_ALLOWED_REGEXP") env_self_hosted = os.environ.get("LEARNHOUSE_SELF_HOSTED") - env_mongodb_connection_string = os.environ.get( - "LEARNHOUSE_MONGODB_CONNECTION_STRING" - ) + env_sql_connection_string = os.environ.get("LEARNHOUSE_SQL_CONNECTION_STRING") # Sentry Config env_sentry_dsn = os.environ.get("LEARNHOUSE_SENTRY_DSN") @@ -166,9 +165,13 @@ def get_learnhouse_config() -> LearnHouseConfig: ) # Database config - mongodb_connection_string = env_mongodb_connection_string or yaml_config.get( + sql_connection_string = env_sql_connection_string or yaml_config.get( "database_config", {} - ).get("mongodb_connection_string") + ).get("sql_connection_string") + + mongo_connection_string = yaml_config.get("database_config", {}).get( + "mongo_connection_string" + ) # Sentry config # check if the sentry config is provided in the YAML file @@ -210,7 +213,8 @@ def get_learnhouse_config() -> LearnHouseConfig: content_delivery=content_delivery, ) database_config = DatabaseConfig( - mongodb_connection_string=mongodb_connection_string + sql_connection_string=sql_connection_string, + mongo_connection_string=mongo_connection_string, ) # Create LearnHouseConfig object diff --git a/apps/api/config/config.yaml b/apps/api/config/config.yaml index 742d57b2..17bea379 100644 --- a/apps/api/config/config.yaml +++ b/apps/api/config/config.yaml @@ -13,8 +13,8 @@ hosting_config: domain: learnhouse.app ssl: true allowed_origins: - - http://localhost:3000 - - http://localhost:3001 + - http://localhost:3000 + - http://localhost:3001 cookies_config: domain: ".localhost" allowed_regexp: '\b((?:https?://)[^\s/$.?#].[^\s]*)\b' @@ -25,4 +25,5 @@ hosting_config: endpoint_url: "" database_config: - mongodb_connection_string: mongodb://learnhouse:learnhouse@mongo:27017/ + sql_connection_string: postgresql://learnhouse:learnhouse@db:5432/learnhouse + mongo_connection_string: mongodb://learnhouse:learnhouse@mongo:27017/ diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 3f69c553..b361beec 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -1,8 +1,10 @@ -fastapi==0.101.1 +fastapi==0.104.1 pydantic>=1.8.0,<2.0.0 +sqlmodel==0.0.10 uvicorn==0.23.2 pymongo==4.3.3 motor==3.1.1 +psycopg2 python-multipart boto3 botocore diff --git a/apps/api/src/core/events/database.py b/apps/api/src/core/events/database.py index 174575a6..2b709236 100644 --- a/apps/api/src/core/events/database.py +++ b/apps/api/src/core/events/database.py @@ -1,21 +1,34 @@ import logging +from config.config import get_learnhouse_config from fastapi import FastAPI +from sqlmodel import SQLModel, Session, create_engine import motor.motor_asyncio +learnhouse_config = get_learnhouse_config() +engine = create_engine( + learnhouse_config.database_config.sql_connection_string, echo=False # type: ignore +) +SQLModel.metadata.create_all(engine) + async def connect_to_db(app: FastAPI): - logging.info("Connecting to database...") - try: - app.mongodb_client = motor.motor_asyncio.AsyncIOMotorClient( # type: ignore - app.learnhouse_config.database_config.mongodb_connection_string) # type: ignore - app.db = app.mongodb_client["learnhouse"] # type: ignore - logging.info("Connected to database!") - except Exception as e: - logging.error("Failed to connect to database!") - logging.error(e) + app.db_engine = engine # type: ignore + logging.info("LearnHouse database has been started.") + SQLModel.metadata.create_all(engine) + + # MongoDB for migration purposes + # mongodb + app.mongodb_client = motor.motor_asyncio.AsyncIOMotorClient( # type: ignore + app.learnhouse_config.database_config.mongo_connection_string # type: ignore + ) # type: ignore + app.db = app.mongodb_client["learnhouse"] # type: ignore + + +def get_db_session(): + with Session(engine) as session: + yield session async def close_database(app: FastAPI): - app.mongodb_client.close() # type: ignore logging.info("LearnHouse has been shut down.") return app diff --git a/apps/api/src/services/dev/mocks/__init__.py b/apps/api/src/db/__init__.py similarity index 100% rename from apps/api/src/services/dev/mocks/__init__.py rename to apps/api/src/db/__init__.py diff --git a/apps/api/src/db/activities.py b/apps/api/src/db/activities.py new file mode 100644 index 00000000..95b6ecef --- /dev/null +++ b/apps/api/src/db/activities.py @@ -0,0 +1,72 @@ +from typing import Optional +from sqlalchemy import JSON, BigInteger, Column, ForeignKey +from sqlmodel import Field, SQLModel +from enum import Enum + + +class ActivityTypeEnum(str, Enum): + TYPE_VIDEO = "TYPE_VIDEO" + TYPE_DOCUMENT = "TYPE_DOCUMENT" + TYPE_DYNAMIC = "TYPE_DYNAMIC" + TYPE_ASSESSMENT = "TYPE_ASSESSMENT" + TYPE_CUSTOM = "TYPE_CUSTOM" + + +class ActivitySubTypeEnum(str, Enum): + # Dynamic + SUBTYPE_DYNAMIC_PAGE = "SUBTYPE_DYNAMIC_PAGE" + # Video + SUBTYPE_VIDEO_YOUTUBE = "SUBTYPE_VIDEO_YOUTUBE" + SUBTYPE_VIDEO_HOSTED = "SUBTYPE_VIDEO_HOSTED" + # Document + SUBTYPE_DOCUMENT_PDF = "SUBTYPE_DOCUMENT_PDF" + SUBTYPE_DOCUMENT_DOC = "SUBTYPE_DOCUMENT_DOC" + # Assessment + SUBTYPE_ASSESSMENT_QUIZ = "SUBTYPE_ASSESSMENT_QUIZ" + # Custom + SUBTYPE_CUSTOM = "SUBTYPE_CUSTOM" + + +class ActivityBase(SQLModel): + name: str + activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM + activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM + content: dict = Field(default={}, sa_column=Column(JSON)) + published_version: int + version: int + + +class Activity(ActivityBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field(default=None, foreign_key="organization.id") + course_id: int = Field( + default=None, + sa_column=Column( + BigInteger, ForeignKey("course.id", ondelete="CASCADE") + ), + ) + activity_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class ActivityCreate(ActivityBase): + chapter_id: int + pass + + +class ActivityUpdate(ActivityBase): + name: Optional[str] + activity_type: Optional[ActivityTypeEnum] + activity_sub_type: Optional[ActivitySubTypeEnum] + content: dict = Field(default={}, sa_column=Column(JSON)) + published_version: Optional[int] + version: Optional[int] + + +class ActivityRead(ActivityBase): + id: int + activity_uuid: str + creation_date: str + update_date: str + pass diff --git a/apps/api/src/db/blocks.py b/apps/api/src/db/blocks.py new file mode 100644 index 00000000..59972a04 --- /dev/null +++ b/apps/api/src/db/blocks.py @@ -0,0 +1,46 @@ +from typing import Optional +from sqlalchemy import JSON, Column, ForeignKey +from sqlmodel import Field, SQLModel +from enum import Enum + + +class BlockTypeEnum(str, Enum): + BLOCK_QUIZ = "BLOCK_QUIZ" + BLOCK_VIDEO = "BLOCK_VIDEO" + BLOCK_DOCUMENT_PDF = "BLOCK_DOCUMENT_PDF" + BLOCK_IMAGE = "BLOCK_IMAGE" + BLOCK_CUSTOM = "BLOCK_CUSTOM" + + +class BlockBase(SQLModel): + id: Optional[int] = Field(default=None, primary_key=True) + block_type: BlockTypeEnum = BlockTypeEnum.BLOCK_CUSTOM + content: dict = Field(default={}, sa_column=Column(JSON)) + + +class Block(BlockBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + content: dict = Field(default={}, sa_column=Column(JSON)) + org_id: int = Field(default=None, foreign_key="organization.id") + course_id: int = Field(sa_column= Column("course_id", ForeignKey("course.id", ondelete="CASCADE"))) + chapter_id: int = Field(sa_column= Column("chapter_id", ForeignKey("chapter.id", ondelete="CASCADE"))) + activity_id: int = Field(sa_column= Column("activity_id", ForeignKey("activity.id", ondelete="CASCADE"))) + block_uuid: str + creation_date: str + update_date: str + + +class BlockCreate(BlockBase): + pass + + +class BlockRead(BlockBase): + id: int + org_id: int = Field(default=None, foreign_key="organization.id") + course_id: int = Field(default=None, foreign_key="course.id") + chapter_id: int = Field(default=None, foreign_key="chapter.id") + activity_id: int = Field(default=None, foreign_key="activity.id") + block_uuid: str + creation_date: str + update_date: str + pass diff --git a/apps/api/src/db/chapter_activities.py b/apps/api/src/db/chapter_activities.py new file mode 100644 index 00000000..936078d9 --- /dev/null +++ b/apps/api/src/db/chapter_activities.py @@ -0,0 +1,13 @@ +from typing import Optional +from sqlalchemy import BigInteger, Column, ForeignKey +from sqlmodel import Field, SQLModel + +class ChapterActivity(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + order: int + chapter_id: int = Field(sa_column=Column(BigInteger, ForeignKey("chapter.id", ondelete="CASCADE"))) + activity_id: int = Field(sa_column=Column(BigInteger, ForeignKey("activity.id", ondelete="CASCADE"))) + course_id : int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE"))) + org_id : int = Field(default=None, foreign_key="organization.id") + creation_date: str + update_date: str \ No newline at end of file diff --git a/apps/api/src/db/chapters.py b/apps/api/src/db/chapters.py new file mode 100644 index 00000000..4e94dc62 --- /dev/null +++ b/apps/api/src/db/chapters.py @@ -0,0 +1,68 @@ +from typing import Any, List, Optional +from pydantic import BaseModel +from sqlalchemy import Column, ForeignKey +from sqlmodel import Field, SQLModel +from src.db.activities import ActivityRead + + +class ChapterBase(SQLModel): + name: str + description: Optional[str] = "" + thumbnail_image: Optional[str] = "" + org_id: int = Field(default=None, foreign_key="organization.id") + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + + +class Chapter(ChapterBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + course_id: int = Field( + sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class ChapterCreate(ChapterBase): + # referenced order here will be ignored and just used for validation + # used order will be the next available. + pass + + +class ChapterUpdate(ChapterBase): + name: Optional[str] + description: Optional[str] + thumbnail_image: Optional[str] + course_id: Optional[int] + org_id: Optional[int] + + +class ChapterRead(ChapterBase): + id: int + activities: List[ActivityRead] + chapter_uuid: str + creation_date: str + update_date: str + pass + + +class ActivityOrder(BaseModel): + activity_id: int + + +class ChapterOrder(BaseModel): + chapter_id: int + activities_order_by_ids: List[ActivityOrder] + + +class ChapterUpdateOrder(BaseModel): + chapter_order_by_ids: List[ChapterOrder] + + +class DepreceatedChaptersRead(BaseModel): + chapterOrder: Any + chapters: Any + activities: Any + pass diff --git a/apps/api/src/db/collections.py b/apps/api/src/db/collections.py new file mode 100644 index 00000000..9b191c8d --- /dev/null +++ b/apps/api/src/db/collections.py @@ -0,0 +1,39 @@ +from typing import Optional +from sqlmodel import Field, SQLModel + + +class CollectionBase(SQLModel): + name: str + public: bool + description: Optional[str] = "" + + +class Collection(CollectionBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field(default=None, foreign_key="organization.id") + collection_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class CollectionCreate(CollectionBase): + courses: list[int] + org_id: int = Field(default=None, foreign_key="organization.id") + + pass + + +class CollectionUpdate(CollectionBase): + courses: Optional[list] + name: Optional[str] + public: Optional[bool] + description: Optional[str] + + +class CollectionRead(CollectionBase): + id: int + courses: list + collection_uuid: str + creation_date: str + update_date: str + pass diff --git a/apps/api/src/db/collections_courses.py b/apps/api/src/db/collections_courses.py new file mode 100644 index 00000000..6b30c2ee --- /dev/null +++ b/apps/api/src/db/collections_courses.py @@ -0,0 +1,12 @@ +from typing import Optional +from sqlalchemy import BigInteger, Column, ForeignKey +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(BigInteger, ForeignKey("collection.id", ondelete="CASCADE"))) + course_id: int = Field(sa_column=Column(BigInteger, 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/course_chapters.py b/apps/api/src/db/course_chapters.py new file mode 100644 index 00000000..dec820c6 --- /dev/null +++ b/apps/api/src/db/course_chapters.py @@ -0,0 +1,17 @@ +from typing import Optional +from sqlalchemy import BigInteger, Column, ForeignKey +from sqlmodel import Field, SQLModel + + +class CourseChapter(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + order: int + course_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE")) + ) + chapter_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("chapter.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.py b/apps/api/src/db/courses.py new file mode 100644 index 00000000..7cc950b1 --- /dev/null +++ b/apps/api/src/db/courses.py @@ -0,0 +1,72 @@ +from typing import List, Optional +from sqlmodel import Field, SQLModel +from src.db.users import UserRead +from src.db.trails import TrailRead +from src.db.chapters import ChapterRead + + +class CourseBase(SQLModel): + name: str + description: Optional[str] + about: Optional[str] + learnings: Optional[str] + tags: Optional[str] + thumbnail_image: Optional[str] + public: bool + + +class Course(CourseBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field(default=None, foreign_key="organization.id") + course_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class CourseCreate(CourseBase): + org_id: int = Field(default=None, foreign_key="organization.id") + pass + + +class CourseUpdate(CourseBase): + name: str + description: Optional[str] + about: Optional[str] + learnings: Optional[str] + tags: Optional[str] + public: Optional[bool] + + +class CourseRead(CourseBase): + id: int + org_id: int = Field(default=None, foreign_key="organization.id") + authors: List[UserRead] + course_uuid: str + creation_date: str + update_date: str + pass + + +class FullCourseRead(CourseBase): + id: int + course_uuid: str + creation_date: str + update_date: str + # Chapters, Activities + chapters: List[ChapterRead] + authors: List[UserRead] + pass + + +class FullCourseReadWithTrail(CourseBase): + id: int + course_uuid: str + creation_date: str + update_date: str + org_id: int = Field(default=None, foreign_key="organization.id") + authors: List[UserRead] + # Chapters, Activities + chapters: List[ChapterRead] + # Trail + trail: TrailRead | None + pass diff --git a/apps/api/src/db/install.py b/apps/api/src/db/install.py new file mode 100644 index 00000000..92624b17 --- /dev/null +++ b/apps/api/src/db/install.py @@ -0,0 +1,31 @@ +from typing import Optional +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class InstallBase(SQLModel): + step: int = Field(default=0) + data: dict = Field(default={}, sa_column=Column(JSON)) + + +class Install(InstallBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + install_uuid: str = Field(default=None) + creation_date: str = "" + update_date: str = "" + + +class InstallCreate(InstallBase): + pass + + +class InstallUpdate(InstallBase): + pass + + +class InstallRead(InstallBase): + id: Optional[int] = Field(default=None, primary_key=True) + install_uuid: str = Field(default=None) + creation_date: str + update_date: str + pass diff --git a/apps/api/src/db/organization_settings.py b/apps/api/src/db/organization_settings.py new file mode 100644 index 00000000..babdef08 --- /dev/null +++ b/apps/api/src/db/organization_settings.py @@ -0,0 +1,21 @@ +from typing import Optional +from sqlalchemy import BigInteger, Column, ForeignKey +from sqlmodel import Field, SQLModel +from enum import Enum + + +class HeaderTypeEnum(str, Enum): + LOGO_MENU_SETTINGS = "LOGO_MENU_SETTINGS" + MENU_LOGO_SETTINGS = "MENU_LOGO_SETTINGS" + + +class OrganizationSettings(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + logo_image: Optional[str] = "" + header_type: HeaderTypeEnum = HeaderTypeEnum.LOGO_MENU_SETTINGS + color: str = "" + creation_date: str + update_date: str diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py new file mode 100644 index 00000000..c16d3809 --- /dev/null +++ b/apps/api/src/db/organizations.py @@ -0,0 +1,30 @@ +from typing import Optional +from sqlmodel import Field, SQLModel + + +class OrganizationBase(SQLModel): + name: str + description: Optional[str] + slug: str + email: str + logo_image: Optional[str] + + +class Organization(OrganizationBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_uuid: str = "" + creation_date: str = "" + update_date: str = "" + +class OrganizationUpdate(OrganizationBase): + pass + +class OrganizationCreate(OrganizationBase): + pass + + +class OrganizationRead(OrganizationBase): + id: int + org_uuid: str + creation_date: str + update_date: str diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py new file mode 100644 index 00000000..758a59c3 --- /dev/null +++ b/apps/api/src/db/resource_authors.py @@ -0,0 +1,18 @@ +from enum import Enum +from typing import Optional +from sqlmodel import Field, SQLModel + + +class ResourceAuthorshipEnum(str, Enum): + CREATOR = "CREATOR" + MAINTAINER = "MAINTAINER" + REPORTER = "REPORTER" + + +class ResourceAuthor(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + resource_uuid: str + user_id: int = Field(default=None, foreign_key="user.id") + authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR + creation_date: str = "" + update_date: str = "" diff --git a/apps/api/src/db/roles.py b/apps/api/src/db/roles.py new file mode 100644 index 00000000..09ff4206 --- /dev/null +++ b/apps/api/src/db/roles.py @@ -0,0 +1,72 @@ +from enum import Enum +from typing import Optional, Union +from pydantic import BaseModel +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +# Rights +class Permission(BaseModel): + action_create: bool + action_read: bool + action_update: bool + action_delete: bool + + def __getitem__(self, item): + return getattr(self, item) + + +class Rights(BaseModel): + courses: Permission + users: Permission + collections: Permission + organizations: Permission + coursechapters: Permission + activities: Permission + + def __getitem__(self, item): + return getattr(self, item) + + +# Database Models + + +class RoleTypeEnum(str, Enum): + TYPE_ORGANIZATION = "TYPE_ORGANIZATION" # Organization roles are associated with an organization, they are used to define the rights of a user in an organization + TYPE_ORGANIZATION_API_TOKEN = "TYPE_ORGANIZATION_API_TOKEN" # Organization API Token roles are associated with an organization, they are used to define the rights of an API Token in an organization + TYPE_GLOBAL = "TYPE_GLOBAL" # Global roles are not associated with an organization, they are used to define the default rights of a user + + +class RoleBase(SQLModel): + name: str + description: Optional[str] + rights: Optional[Union[Rights, dict]] = Field(default={}, sa_column=Column(JSON)) + + +class Role(RoleBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field(default=None, foreign_key="organization.id") + role_type: RoleTypeEnum = RoleTypeEnum.TYPE_GLOBAL + role_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class RoleRead(RoleBase): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field(default=None, foreign_key="organization.id") + role_type: RoleTypeEnum = RoleTypeEnum.TYPE_GLOBAL + role_uuid: str + creation_date: str + update_date: str + + +class RoleCreate(RoleBase): + org_id: Optional[int] = Field(default=None, foreign_key="organization.id") + + +class RoleUpdate(SQLModel): + role_id: int = Field(default=None, foreign_key="role.id") + name: Optional[str] + description: Optional[str] + rights: Optional[Union[Rights, dict]] = Field(default={}, sa_column=Column(JSON)) diff --git a/apps/api/src/db/trail_runs.py b/apps/api/src/db/trail_runs.py new file mode 100644 index 00000000..e160a790 --- /dev/null +++ b/apps/api/src/db/trail_runs.py @@ -0,0 +1,57 @@ +from typing import Optional +from pydantic import BaseModel +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel +from enum import Enum + +from src.db.trail_steps import TrailStep + + +class TrailRunEnum(str, Enum): + RUN_TYPE_COURSE = "RUN_TYPE_COURSE" + + +class StatusEnum(str, Enum): + STATUS_IN_PROGRESS = "STATUS_IN_PROGRESS" + STATUS_COMPLETED = "STATUS_COMPLETED" + STATUS_PAUSED = "STATUS_PAUSED" + STATUS_CANCELLED = "STATUS_CANCELLED" + + +class TrailRun(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + data: dict = Field(default={}, sa_column=Column(JSON)) + status: StatusEnum = StatusEnum.STATUS_IN_PROGRESS + # foreign keys + trail_id: int = Field(default=None, foreign_key="trail.id") + course_id: int = Field(default=None, foreign_key="course.id") + org_id: int = Field(default=None, foreign_key="organization.id") + user_id: int = Field(default=None, foreign_key="user.id") + # timestamps + creation_date: str + update_date: str + + +class TrailRunCreate(TrailRun): + pass + + +# trick because Lists are not supported in SQLModel (runs: list[TrailStep] ) +class TrailRunRead(BaseModel): + id: Optional[int] = Field(default=None, primary_key=True) + data: dict = Field(default={}, sa_column=Column(JSON)) + status: StatusEnum = StatusEnum.STATUS_IN_PROGRESS + # foreign keys + trail_id: int = Field(default=None, foreign_key="trail.id") + course_id: int = Field(default=None, foreign_key="course.id") + org_id: int = Field(default=None, foreign_key="organization.id") + user_id: int = Field(default=None, foreign_key="user.id") + # course object + course: dict + # timestamps + creation_date: str + update_date: str + # number of activities in course + course_total_steps: int + steps: list[TrailStep] + pass diff --git a/apps/api/src/db/trail_steps.py b/apps/api/src/db/trail_steps.py new file mode 100644 index 00000000..af13c95d --- /dev/null +++ b/apps/api/src/db/trail_steps.py @@ -0,0 +1,34 @@ +from enum import Enum +from typing import Optional +from sqlmodel import Field, SQLModel +from sqlalchemy import BigInteger, ForeignKey, JSON, Column + + +class TrailStepTypeEnum(str, Enum): + STEP_TYPE_READABLE_ACTIVITY = "STEP_TYPE_READABLE_ACTIVITY" + STEP_TYPE_ASSIGNMENT_ACTIVITY = "STEP_TYPE_ASSIGNMENT_ACTIVITY" + STEP_TYPE_CUSTOM_ACTIVITY = "STEP_TYPE_CUSTOM_ACTIVITY" + + +class TrailStep(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + complete: bool + teacher_verified: bool + grade: str + data: dict = Field(default={}, sa_column=Column(JSON)) + # foreign keys + trailrun_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("trailrun.id", ondelete="CASCADE")) + ) + trail_id: int = Field(default=None, foreign_key="trail.id") + activity_id: int = Field(default=None, foreign_key="activity.id") + course_id: int = Field(default=None, foreign_key="course.id") + org_id: int = Field(default=None, foreign_key="organization.id") + user_id: int = Field(default=None, foreign_key="user.id") + # timestamps + creation_date: str + update_date: str + + +# note : prepare assignments support +# an assignment object will be linked to a trail step object in the future diff --git a/apps/api/src/db/trails.py b/apps/api/src/db/trails.py new file mode 100644 index 00000000..c59697ef --- /dev/null +++ b/apps/api/src/db/trails.py @@ -0,0 +1,34 @@ +from typing import Optional +from pydantic import BaseModel +from sqlmodel import Field, SQLModel +from src.db.trail_runs import TrailRunRead + + +class TrailBase(SQLModel): + org_id: int = Field(default=None, foreign_key="organization.id") + user_id: int = Field(default=None, foreign_key="user.id") + + +class Trail(TrailBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + trail_uuid: str = "" + creation_date: str = "" + update_date: str = "" + + +class TrailCreate(TrailBase): + pass + + +# trick because Lists are not supported in SQLModel (runs: list[TrailRun] ) +class TrailRead(BaseModel): + id: Optional[int] = Field(default=None, primary_key=True) + trail_uuid: str + org_id: int = Field(default=None, foreign_key="organization.id") + user_id: int = Field(default=None, foreign_key="user.id") + creation_date: str + update_date: str + runs: list[TrailRunRead] + + class Config: + orm_mode = True diff --git a/apps/api/src/db/user_organizations.py b/apps/api/src/db/user_organizations.py new file mode 100644 index 00000000..c842d41c --- /dev/null +++ b/apps/api/src/db/user_organizations.py @@ -0,0 +1,14 @@ +from typing import Optional +from sqlalchemy import BigInteger, Column, ForeignKey +from sqlmodel import Field, SQLModel + + +class UserOrganization(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(default=None, foreign_key="user.id") + org_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")) + ) + role_id: int = Field(default=None, foreign_key="role.id") + creation_date: str + update_date: str diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py new file mode 100644 index 00000000..79ee9788 --- /dev/null +++ b/apps/api/src/db/users.py @@ -0,0 +1,50 @@ +from typing import Optional +from sqlmodel import Field, SQLModel + + +class UserBase(SQLModel): + username: str + first_name: str + last_name: str + email: str + avatar_image: Optional[str] = "" + bio: Optional[str] = "" + + +class UserCreate(UserBase): + password: str + + +class UserUpdate(UserBase): + username: str + first_name: Optional[str] + last_name: Optional[str] + email: str + avatar_image: Optional[str] = "" + bio: Optional[str] = "" + + +class UserUpdatePassword(SQLModel): + old_password: str + new_password: str + + +class UserRead(UserBase): + id: int + user_uuid: str + +class PublicUser(UserRead): + pass + +class AnonymousUser(SQLModel): + id: int = 0 + user_uuid: str = "user_anonymous" + username: str = "anonymous" + +class User(UserBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + password: str = "" + user_uuid: str = "" + email_verified: bool = False + creation_date: str = "" + update_date: str = "" diff --git a/apps/api/src/router.py b/apps/api/src/router.py index 895873b3..6931d66b 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -35,3 +35,4 @@ v1_router.include_router( tags=["install"], dependencies=[Depends(isInstallModeEnabled)], ) + diff --git a/apps/api/src/routers/auth.py b/apps/api/src/routers/auth.py index 54e4dc7a..307ad70b 100644 --- a/apps/api/src/routers/auth.py +++ b/apps/api/src/routers/auth.py @@ -1,15 +1,17 @@ from fastapi import Depends, APIRouter, HTTPException, Response, status, Request from fastapi.security import OAuth2PasswordRequestForm +from sqlmodel import Session +from src.db.users import UserRead +from src.core.events.database import get_db_session from config.config import get_learnhouse_config from src.security.auth import AuthJWT, authenticate_user -from src.services.users.users import PublicUser router = APIRouter() @router.post("/refresh") -def refresh(response: Response,Authorize: AuthJWT = Depends()): +def refresh(response: Response, Authorize: AuthJWT = Depends()): """ The jwt_refresh_token_required() function insures a valid refresh token is present in the request before running any code below that function. @@ -21,7 +23,12 @@ def refresh(response: Response,Authorize: AuthJWT = Depends()): current_user = Authorize.get_jwt_subject() new_access_token = Authorize.create_access_token(subject=current_user) # type: ignore - response.set_cookie(key="access_token_cookie", value=new_access_token, httponly=False, domain=get_learnhouse_config().hosting_config.cookie_config.domain) + response.set_cookie( + key="access_token_cookie", + value=new_access_token, + httponly=False, + domain=get_learnhouse_config().hosting_config.cookie_config.domain, + ) return {"access_token": new_access_token} @@ -31,8 +38,11 @@ async def login( response: Response, Authorize: AuthJWT = Depends(), form_data: OAuth2PasswordRequestForm = Depends(), + db_session: Session = Depends(get_db_session), ): - user = await authenticate_user(request, form_data.username, form_data.password) + user = await authenticate_user( + request, form_data.username, form_data.password, db_session + ) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -44,8 +54,14 @@ async def login( refresh_token = Authorize.create_refresh_token(subject=form_data.username) Authorize.set_refresh_cookies(refresh_token) # set cookies using fastapi - response.set_cookie(key="access_token_cookie", value=access_token, httponly=False, domain=get_learnhouse_config().hosting_config.cookie_config.domain) - user = PublicUser(**user.dict()) + response.set_cookie( + key="access_token_cookie", + value=access_token, + httponly=False, + domain=get_learnhouse_config().hosting_config.cookie_config.domain, + ) + + user = UserRead.from_orm(user) result = { "user": user, diff --git a/apps/api/src/routers/blocks.py b/apps/api/src/routers/blocks.py index 3d2c4929..19f4b03b 100644 --- a/apps/api/src/routers/blocks.py +++ b/apps/api/src/routers/blocks.py @@ -1,9 +1,20 @@ from fastapi import APIRouter, Depends, UploadFile, Form, Request +from src.db.blocks import BlockRead +from src.core.events.database import get_db_session from src.security.auth import get_current_user -from src.services.blocks.block_types.imageBlock.images import create_image_block, get_image_block -from src.services.blocks.block_types.videoBlock.videoBlock import create_video_block, get_video_block -from src.services.blocks.block_types.pdfBlock.pdfBlock import create_pdf_block, get_pdf_block -from src.services.blocks.block_types.quizBlock.quizBlock import create_quiz_block, get_quiz_block_answers, get_quiz_block_options, quizBlock +from src.services.blocks.block_types.imageBlock.imageBlock import ( + create_image_block, + get_image_block, +) +from src.services.blocks.block_types.videoBlock.videoBlock import ( + create_video_block, + get_video_block, +) +from src.services.blocks.block_types.pdfBlock.pdfBlock import ( + create_pdf_block, + get_pdf_block, +) + from src.services.users.users import PublicUser router = APIRouter() @@ -12,83 +23,93 @@ router = APIRouter() # Image Block #################### + @router.post("/image") -async def api_create_image_file_block(request: Request, file_object: UploadFile, activity_id: str = Form(), current_user: PublicUser = Depends(get_current_user)): +async def api_create_image_file_block( + request: Request, + file_object: UploadFile, + activity_uuid: str = Form(), + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Create new image file """ - return await create_image_block(request, file_object, activity_id) + return await create_image_block(request, file_object, activity_uuid, db_session) @router.get("/image") -async def api_get_image_file_block(request: Request, file_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_image_file_block( + request: Request, + block_uuid: str, + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Get image file """ - return await get_image_block(request, file_id, current_user) + return await get_image_block(request, block_uuid, current_user, db_session) + #################### # Video Block #################### + @router.post("/video") -async def api_create_video_file_block(request: Request, file_object: UploadFile, activity_id: str = Form(), current_user: PublicUser = Depends(get_current_user)): +async def api_create_video_file_block( + request: Request, + file_object: UploadFile, + activity_uuid: str = Form(), + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Create new video file """ - return await create_video_block(request, file_object, activity_id) + return await create_video_block(request, file_object, activity_uuid, db_session) @router.get("/video") -async def api_get_video_file_block(request: Request, file_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_video_file_block( + request: Request, + block_uuid: str, + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Get video file """ - return await get_video_block(request, file_id, current_user) + return await get_video_block(request, block_uuid, current_user, db_session) + #################### # PDF Block #################### + @router.post("/pdf") -async def api_create_pdf_file_block(request: Request, file_object: UploadFile, activity_id: str = Form(), current_user: PublicUser = Depends(get_current_user)): +async def api_create_pdf_file_block( + request: Request, + file_object: UploadFile, + activity_uuid: str = Form(), + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Create new pdf file """ - return await create_pdf_block(request, file_object, activity_id) + return await create_pdf_block(request, file_object, activity_uuid, db_session) @router.get("/pdf") -async def api_get_pdf_file_block(request: Request, file_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_pdf_file_block( + request: Request, + block_uuid: str, + db_session=Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> BlockRead: """ Get pdf file """ - return await get_pdf_block(request, file_id, current_user) - - -#################### -# Quiz Block -#################### - -@router.post("/quiz/{activity_id}") -async def api_create_quiz_block(request: Request, quiz_block: quizBlock, activity_id: str, current_user: PublicUser = Depends(get_current_user)): - """ - Create new document file - """ - return await create_quiz_block(request, quiz_block, activity_id, current_user) - - -@router.get("/quiz/options") -async def api_get_quiz_options(request: Request, block_id: str, current_user: PublicUser = Depends(get_current_user)): - """ - Get quiz options - """ - return await get_quiz_block_options(request, block_id, current_user) - - -@router.get("/quiz/answers") -async def api_get_quiz_answers(request: Request, block_id: str, current_user: PublicUser = Depends(get_current_user)): - """ - Get quiz answers - """ - return await get_quiz_block_answers(request, block_id, current_user) + return await get_pdf_block(request, block_uuid, current_user, db_session) diff --git a/apps/api/src/routers/courses/activities.py b/apps/api/src/routers/courses/activities.py index f04af1d7..8afad228 100644 --- a/apps/api/src/routers/courses/activities.py +++ b/apps/api/src/routers/courses/activities.py @@ -1,6 +1,9 @@ +from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request +from src.db.activities import ActivityCreate, ActivityRead, ActivityUpdate +from src.db.users import PublicUser +from src.core.events.database import get_db_session from src.services.courses.activities.activities import ( - Activity, create_activity, get_activity, get_activities, @@ -14,7 +17,6 @@ from src.services.courses.activities.video import ( create_external_video_activity, create_video_activity, ) -from src.services.users.schemas.users import PublicUser router = APIRouter() @@ -22,17 +24,14 @@ router = APIRouter() @router.post("/") async def api_create_activity( request: Request, - activity_object: Activity, - org_id: str, - coursechapter_id: str, + activity_object: ActivityCreate, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Create new activity """ - return await create_activity( - request, activity_object, org_id, coursechapter_id, current_user - ) + return await create_activity(request, activity_object, current_user, db_session) @router.get("/{activity_id}") @@ -40,36 +39,43 @@ async def api_get_activity( request: Request, activity_id: str, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Get single activity by activity_id """ - return await get_activity(request, activity_id, current_user=current_user) + return await get_activity( + request, activity_id, current_user=current_user, db_session=db_session + ) -@router.get("/coursechapter/{coursechapter_id}") -async def api_get_activities( +@router.get("/chapter/{chapter_id}") +async def api_get_chapter_activities( request: Request, - coursechapter_id: str, + chapter_id: int, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> List[ActivityRead]: """ - Get CourseChapter activities + Get Activities for a chapter """ - return await get_activities(request, coursechapter_id, current_user) + return await get_activities(request, chapter_id, current_user, db_session) -@router.put("/{activity_id}") +@router.put("/{activity_uuid}") async def api_update_activity( request: Request, - activity_object: Activity, - activity_id: str, + activity_object: ActivityUpdate, + activity_uuid: str, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Update activity by activity_id """ - return await update_activity(request, activity_object, activity_id, current_user) + return await update_activity( + request, activity_object, activity_uuid, current_user, db_session + ) @router.delete("/{activity_id}") @@ -77,11 +83,12 @@ async def api_delete_activity( request: Request, activity_id: str, current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), ): """ Delete activity by activity_id """ - return await delete_activity(request, activity_id, current_user) + return await delete_activity(request, activity_id, current_user, db_session) # Video activity @@ -91,15 +98,21 @@ async def api_delete_activity( async def api_create_video_activity( request: Request, name: str = Form(), - coursechapter_id: str = Form(), + chapter_id: str = Form(), current_user: PublicUser = Depends(get_current_user), video_file: UploadFile | None = None, -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Create new activity """ return await create_video_activity( - request, name, coursechapter_id, current_user, video_file + request, + name, + chapter_id, + current_user, + db_session, + video_file, ) @@ -108,24 +121,28 @@ async def api_create_external_video_activity( request: Request, external_video: ExternalVideo, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Create new activity """ - return await create_external_video_activity(request, current_user, external_video) + return await create_external_video_activity( + request, current_user, external_video, db_session + ) @router.post("/documentpdf") async def api_create_documentpdf_activity( request: Request, name: str = Form(), - coursechapter_id: str = Form(), + chapter_id: str = Form(), current_user: PublicUser = Depends(get_current_user), pdf_file: UploadFile | None = None, -): + db_session=Depends(get_db_session), +) -> ActivityRead: """ Create new activity """ return await create_documentpdf_activity( - request, name, coursechapter_id, current_user, pdf_file + request, name, chapter_id, current_user, db_session, pdf_file ) diff --git a/apps/api/src/routers/courses/chapters.py b/apps/api/src/routers/courses/chapters.py index 213dcc55..f6cf42db 100644 --- a/apps/api/src/routers/courses/chapters.py +++ b/apps/api/src/routers/courses/chapters.py @@ -1,6 +1,22 @@ +from typing import List from fastapi import APIRouter, Depends, Request +from src.core.events.database import get_db_session +from src.db.chapters import ( + ChapterCreate, + ChapterRead, + ChapterUpdate, + ChapterUpdateOrder, +) +from src.services.courses.chapters import ( + DEPRECEATED_get_course_chapters, + create_chapter, + delete_chapter, + get_chapter, + get_course_chapters, + reorder_chapters_and_activities, + update_chapter, +) -from src.services.courses.chapters import CourseChapter, CourseChapterMetaData, create_coursechapter, delete_coursechapter, get_coursechapter, get_coursechapters, get_coursechapters_meta, update_coursechapter, update_coursechapters_meta from src.services.users.users import PublicUser from src.security.auth import get_current_user @@ -8,57 +24,104 @@ router = APIRouter() @router.post("/") -async def api_create_coursechapter(request: Request,coursechapter_object: CourseChapter, course_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_create_coursechapter( + request: Request, + coursechapter_object: ChapterCreate, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> ChapterRead: """ - Create new CourseChapter + Create new Course Chapter """ - return await create_coursechapter(request, coursechapter_object, course_id, current_user) + return await create_chapter(request, coursechapter_object, current_user, db_session) -@router.get("/{coursechapter_id}") -async def api_get_coursechapter(request: Request,coursechapter_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/{chapter_id}") +async def api_get_coursechapter( + request: Request, + chapter_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> ChapterRead: """ - Get single CourseChapter by coursechapter_id + Get single CourseChapter by chapter_id """ - return await get_coursechapter(request, coursechapter_id, current_user=current_user) + return await get_chapter(request, chapter_id, current_user, db_session) -@router.get("/meta/{course_id}") -async def api_get_coursechapter_meta(request: Request,course_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/course/{course_uuid}/meta", deprecated=True) +async def api_get_chapter_meta( + request: Request, + course_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): """ - Get coursechapter metadata + Get Chapters metadata """ - return await get_coursechapters_meta(request, course_id, current_user=current_user) + return await DEPRECEATED_get_course_chapters( + request, course_uuid, current_user, db_session + ) -@router.put("/meta/{course_id}") -async def api_update_coursechapter_meta(request: Request,course_id: str, coursechapters_metadata: CourseChapterMetaData, current_user: PublicUser = Depends(get_current_user)): +@router.put("/course/{course_uuid}/order") +async def api_update_chapter_meta( + request: Request, + course_uuid: str, + order: ChapterUpdateOrder, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): """ - Update coursechapter metadata + Update Chapter metadata """ - return await update_coursechapters_meta(request, course_id, coursechapters_metadata, current_user=current_user) + return await reorder_chapters_and_activities( + request, course_uuid, order, current_user, db_session + ) -@router.get("/{course_id}/page/{page}/limit/{limit}") -async def api_get_coursechapter_by(request: Request,course_id: str, page: int, limit: int): +@router.get("/course/{course_id}/page/{page}/limit/{limit}") +async def api_get_chapter_by( + request: Request, + course_id: int, + page: int, + limit: int, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> List[ChapterRead]: """ - Get CourseChapters by page and limit + Get Course Chapters by page and limit """ - return await get_coursechapters(request, course_id, page, limit) + return await get_course_chapters( + request, course_id, db_session, current_user, page, limit + ) -@router.put("/{coursechapter_id}") -async def api_update_coursechapter(request: Request,coursechapter_object: CourseChapter, coursechapter_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.put("/{chapter_id}") +async def api_update_coursechapter( + request: Request, + coursechapter_object: ChapterUpdate, + chapter_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +) -> ChapterRead: """ Update CourseChapters by course_id """ - return await update_coursechapter(request, coursechapter_object, coursechapter_id, current_user) + return await update_chapter( + request, coursechapter_object, chapter_id, current_user, db_session + ) -@router.delete("/{coursechapter_id}") -async def api_delete_coursechapter(request: Request,coursechapter_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.delete("/{chapter_id}") +async def api_delete_coursechapter( + request: Request, + chapter_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), +): """ Delete CourseChapters by ID """ - return await delete_coursechapter(request,coursechapter_id, current_user) + return await delete_chapter(request, chapter_id, current_user, db_session) diff --git a/apps/api/src/routers/courses/collections.py b/apps/api/src/routers/courses/collections.py index 87dfe81c..12fcfb66 100644 --- a/apps/api/src/routers/courses/collections.py +++ b/apps/api/src/routers/courses/collections.py @@ -1,8 +1,10 @@ +from typing import List from fastapi import APIRouter, Depends, Request +from src.core.events.database import get_db_session +from src.db.collections import CollectionCreate, CollectionRead, CollectionUpdate from src.security.auth import get_current_user from src.services.users.users import PublicUser from src.services.courses.collections import ( - Collection, create_collection, get_collection, get_collections, @@ -17,64 +19,69 @@ router = APIRouter() @router.post("/") async def api_create_collection( request: Request, - collection_object: Collection, + collection_object: CollectionCreate, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> CollectionRead: """ Create new Collection """ - return await create_collection(request, collection_object, current_user) + return await create_collection(request, collection_object, current_user, db_session) -@router.get("/{collection_id}") +@router.get("/{collection_uuid}") async def api_get_collection( request: Request, - collection_id: str, + collection_uuid: str, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> CollectionRead: """ Get single collection by ID """ - return await get_collection(request, collection_id, current_user) + return await get_collection(request, collection_uuid, current_user, db_session) -@router.get("/org_id/{org_id}/page/{page}/limit/{limit}") +@router.get("/org/{org_id}/page/{page}/limit/{limit}") async def api_get_collections_by( request: Request, page: int, limit: int, org_id: str, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> List[CollectionRead]: """ Get collections by page and limit """ - return await get_collections(request, org_id, current_user, page, limit) + return await get_collections(request, org_id, current_user, db_session, page, limit) -@router.put("/{collection_id}") +@router.put("/{collection_uuid}") async def api_update_collection( request: Request, - collection_object: Collection, - collection_id: str, + collection_object: CollectionUpdate, + collection_uuid: str, current_user: PublicUser = Depends(get_current_user), -): + db_session=Depends(get_db_session), +) -> CollectionRead: """ Update collection by ID """ return await update_collection( - request, collection_object, collection_id, current_user + request, collection_object, collection_uuid, current_user, db_session ) -@router.delete("/{collection_id}") +@router.delete("/{collection_uuid}") async def api_delete_collection( request: Request, - collection_id: str, + collection_uuid: str, current_user: PublicUser = Depends(get_current_user), + db_session=Depends(get_db_session), ): """ Delete collection by ID """ - return await delete_collection(request, collection_id, current_user) + return await delete_collection(request, collection_uuid, current_user, db_session) diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 4aed4c99..f9485e80 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -1,66 +1,147 @@ +from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.db.users import PublicUser +from src.db.courses import ( + CourseCreate, + CourseRead, + CourseUpdate, + FullCourseReadWithTrail, +) from src.security.auth import get_current_user - -from src.services.courses.courses import Course, create_course, get_course, get_course_meta, get_courses_orgslug, update_course, delete_course, update_course_thumbnail -from src.services.users.users import PublicUser +from src.services.courses.courses import ( + create_course, + get_course, + get_course_meta, + get_courses_orgslug, + update_course, + delete_course, + update_course_thumbnail, +) router = APIRouter() @router.post("/") -async def api_create_course(request: Request, org_id: str, name: str = Form(), mini_description: str = Form(), description: str = Form(), public: bool = Form(), current_user: PublicUser = Depends(get_current_user), thumbnail: UploadFile | None = None): +async def api_create_course( + request: Request, + org_id: int, + name: str = Form(), + description: str = Form(), + public: bool = Form(), + learnings: str = Form(), + tags: str = Form(), + about: str = Form(), + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), + thumbnail: UploadFile | None = None, +) -> CourseRead: """ Create new Course """ - course = Course(name=name, mini_description=mini_description, description=description, - org_id=org_id, public=public, thumbnail="", chapters=[], chapters_content=[], learnings=[]) - return await create_course(request, course, org_id, current_user, thumbnail) + course = CourseCreate( + name=name, + description=description, + org_id=org_id, + public=public, + thumbnail_image="", + about=about, + learnings=learnings, + tags=tags, + ) + return await create_course(request, org_id, course, current_user, db_session, thumbnail) -@router.put("/thumbnail/{course_id}") -async def api_create_course_thumbnail(request: Request, course_id: str, thumbnail: UploadFile | None = None, current_user: PublicUser = Depends(get_current_user)): +@router.put("/{course_uuid}/thumbnail") +async def api_create_course_thumbnail( + request: Request, + course_uuid: str, + thumbnail: UploadFile | None = None, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> CourseRead: """ Update new Course Thumbnail """ - return await update_course_thumbnail(request, course_id, current_user, thumbnail) + return await update_course_thumbnail( + request, course_uuid, current_user, db_session, thumbnail + ) -@router.get("/{course_id}") -async def api_get_course(request: Request, course_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/{course_uuid}") +async def api_get_course( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> CourseRead: """ - Get single Course by course_id + Get single Course by course_uuid """ - return await get_course(request, course_id, current_user=current_user) + return await get_course( + request, course_uuid, current_user=current_user, db_session=db_session + ) -@router.get("/meta/{course_id}") -async def api_get_course_meta(request: Request, course_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/{course_uuid}/meta") +async def api_get_course_meta( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> FullCourseReadWithTrail: """ - Get single Course Metadata (chapters, activities) by course_id + Get single Course Metadata (chapters, activities) by course_uuid """ - return await get_course_meta(request, course_id, current_user=current_user) + return await get_course_meta( + request, course_uuid, current_user=current_user, db_session=db_session + ) + @router.get("/org_slug/{org_slug}/page/{page}/limit/{limit}") -async def api_get_course_by_orgslug(request: Request, page: int, limit: int, org_slug: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_course_by_orgslug( + request: Request, + page: int, + limit: int, + org_slug: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> List[CourseRead]: """ - Get houses by page and limit + Get courses by page and limit """ - return await get_courses_orgslug(request, current_user, page, limit, org_slug) + return await get_courses_orgslug( + request, current_user, org_slug, db_session, page, limit + ) -@router.put("/{course_id}") -async def api_update_course(request: Request, course_object: Course, course_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.put("/{course_uuid}") +async def api_update_course( + request: Request, + course_object: CourseUpdate, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> CourseRead: """ - Update Course by course_id + Update Course by course_uuid """ - return await update_course(request, course_object, course_id, current_user) + return await update_course( + request, course_object, course_uuid, current_user, db_session + ) -@router.delete("/{course_id}") -async def api_delete_course(request: Request, course_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.delete("/{course_uuid}") +async def api_delete_course( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): """ Delete Course by ID """ - return await delete_course(request, course_id, current_user) + return await delete_course(request, course_uuid, current_user, db_session) diff --git a/apps/api/src/routers/dev.py b/apps/api/src/routers/dev.py index 318154e8..2c8da946 100644 --- a/apps/api/src/routers/dev.py +++ b/apps/api/src/routers/dev.py @@ -1,6 +1,8 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.services.dev.migration_from_mongo import start_migrate_from_mongo from config.config import get_learnhouse_config -from src.services.dev.mocks.initial import create_initial_data router = APIRouter() @@ -12,7 +14,9 @@ async def config(): return config.dict() -@router.get("/mock/initial") -async def initial_data(request: Request): - await create_initial_data(request) - return {"Message": "Initial data created 🤖"} +@router.get("/migrate_from_mongo") +async def migrate_from_mongo( + request: Request, + db_session: Session = Depends(get_db_session), +): + return await start_migrate_from_mongo(request, db_session) diff --git a/apps/api/src/routers/install/install.py b/apps/api/src/routers/install/install.py index 52f6d5f2..38bebb69 100644 --- a/apps/api/src/routers/install/install.py +++ b/apps/api/src/routers/install/install.py @@ -1,70 +1,87 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request +from src.db.install import InstallRead +from src.core.events.database import get_db_session +from src.db.organizations import OrganizationCreate +from src.db.users import UserCreate from src.services.install.install import ( create_install_instance, - create_sample_data, get_latest_install_instance, install_create_organization, install_create_organization_user, install_default_elements, update_install_instance, ) -from src.services.orgs.schemas.orgs import Organization -from src.services.users.schemas.users import UserWithPassword router = APIRouter() @router.post("/start") -async def api_create_install_instance(request: Request, data: dict): +async def api_create_install_instance( + request: Request, + data: dict, + db_session=Depends(get_db_session), +) -> InstallRead: # create install - install = await create_install_instance(request, data) + install = await create_install_instance(request, data, db_session) return install @router.get("/latest") -async def api_get_latest_install_instance(request: Request): +async def api_get_latest_install_instance( + request: Request, db_session=Depends(get_db_session) +) -> InstallRead: # get latest created install - install = await get_latest_install_instance(request) + install = await get_latest_install_instance(request, db_session=db_session) return install @router.post("/default_elements") -async def api_install_def_elements(request: Request): - elements = await install_default_elements(request, {}) +async def api_install_def_elements( + request: Request, + db_session=Depends(get_db_session), +): + elements = await install_default_elements(request, {}, db_session) return elements @router.post("/org") -async def api_install_org(request: Request, org: Organization): - organization = await install_create_organization(request, org) +async def api_install_org( + request: Request, + org: OrganizationCreate, + db_session=Depends(get_db_session), +): + organization = await install_create_organization(request, org, db_session) return organization @router.post("/user") -async def api_install_user(request: Request, data: UserWithPassword, org_slug: str): - user = await install_create_organization_user(request, data, org_slug) +async def api_install_user( + request: Request, + data: UserCreate, + org_slug: str, + db_session=Depends(get_db_session), +): + user = await install_create_organization_user(request, data, org_slug, db_session) return user -@router.post("/sample") -async def api_install_user_sample(request: Request, username: str, org_slug: str): - sample = await create_sample_data(org_slug, username, request) - - return sample - - @router.post("/update") -async def api_update_install_instance(request: Request, data: dict, step: int): +async def api_update_install_instance( + request: Request, + data: dict, + step: int, + db_session=Depends(get_db_session), +) -> InstallRead: request.app.db["installs"] # get latest created install - install = await update_install_instance(request, data, step) + install = await update_install_instance(request, data, step, db_session) return install diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index f3e2928f..1b39c4e8 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -1,63 +1,127 @@ - +from typing import List from fastapi import APIRouter, Depends, Request, UploadFile +from sqlmodel import Session +from src.db.users import PublicUser +from src.db.organizations import ( + Organization, + OrganizationCreate, + OrganizationRead, + OrganizationUpdate, +) +from src.core.events.database import get_db_session from src.security.auth import get_current_user -from src.services.orgs.orgs import Organization, create_org, delete_org, get_organization, get_organization_by_slug, get_orgs_by_user, update_org, update_org_logo -from src.services.users.users import PublicUser, User +from src.services.orgs.orgs import ( + create_org, + delete_org, + get_organization, + get_organization_by_slug, + get_orgs_by_user, + update_org, + update_org_logo, +) router = APIRouter() @router.post("/") -async def api_create_org(request: Request, org_object: Organization, current_user: PublicUser = Depends(get_current_user)): +async def api_create_org( + request: Request, + org_object: OrganizationCreate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> OrganizationRead: """ Create new organization """ - return await create_org(request, org_object, current_user) + return await create_org(request, org_object, current_user, db_session) @router.get("/{org_id}") -async def api_get_org(request: Request, org_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_org( + request: Request, + org_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> OrganizationRead: """ Get single Org by ID """ - return await get_organization(request, org_id) + return await get_organization(request, org_id, db_session, current_user) @router.get("/slug/{org_slug}") -async def api_get_org_by_slug(request: Request, org_slug: str, current_user: User = Depends(get_current_user)): +async def api_get_org_by_slug( + request: Request, + org_slug: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> OrganizationRead: """ Get single Org by Slug """ - return await get_organization_by_slug(request, org_slug) + return await get_organization_by_slug(request, org_slug, db_session, current_user) + @router.put("/{org_id}/logo") -async def api_update_org_logo(request: Request, org_id: str, logo_file:UploadFile, current_user: PublicUser = Depends(get_current_user)): +async def api_update_org_logo( + request: Request, + org_id: str, + logo_file: UploadFile, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): """ Get single Org by Slug """ - return await update_org_logo(request=request,logo_file=logo_file, org_id=org_id, current_user=current_user) + return await update_org_logo( + request=request, + logo_file=logo_file, + org_id=org_id, + current_user=current_user, + db_session=db_session, + ) + @router.get("/user/page/{page}/limit/{limit}") -async def api_user_orgs(request: Request, page: int, limit: int, current_user: PublicUser = Depends(get_current_user)): +async def api_user_orgs( + request: Request, + page: int, + limit: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> List[Organization]: """ - Get orgs by page and limit by user + Get orgs by page and limit by current user """ - return await get_orgs_by_user(request, current_user.user_id, page, limit) + return await get_orgs_by_user( + request, db_session, str(current_user.id), page, limit + ) @router.put("/{org_id}") -async def api_update_org(request: Request, org_object: Organization, org_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_update_org( + request: Request, + org_object: OrganizationUpdate, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> OrganizationRead: """ Update Org by ID """ - return await update_org(request, org_object, org_id, current_user) + return await update_org(request, org_object,org_id, current_user, db_session) @router.delete("/{org_id}") -async def api_delete_org(request: Request, org_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_delete_org( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): """ Delete Org by ID """ - return await delete_org(request, org_id, current_user) + return await delete_org(request, org_id, current_user, db_session) diff --git a/apps/api/src/routers/roles.py b/apps/api/src/routers/roles.py index d65891bf..ef9350e0 100644 --- a/apps/api/src/routers/roles.py +++ b/apps/api/src/routers/roles.py @@ -1,41 +1,63 @@ from fastapi import APIRouter, Depends, Request +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.db.roles import RoleCreate, RoleRead, RoleUpdate from src.security.auth import get_current_user -from src.services.roles.schemas.roles import Role -from src.services.roles.roles import create_role, delete_role, read_role, update_role -from src.services.users.schemas.users import PublicUser +from src.services.roles.roles import create_role, delete_role, read_role, update_role +from src.db.users import PublicUser router = APIRouter() @router.post("/") -async def api_create_role(request: Request, role_object: Role, current_user: PublicUser = Depends(get_current_user)): +async def api_create_role( + request: Request, + role_object: RoleCreate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +)-> RoleRead: """ Create new role """ - return await create_role(request, role_object, current_user) + return await create_role(request, db_session, role_object, current_user) @router.get("/{role_id}") -async def api_get_role(request: Request, role_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_get_role( + request: Request, + role_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +)-> RoleRead: """ Get single role by role_id """ - return await read_role(request, role_id, current_user) + return await read_role(request, db_session, role_id, current_user) @router.put("/{role_id}") -async def api_update_role(request: Request, role_object: Role, role_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_update_role( + request: Request, + role_object: RoleUpdate, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +)-> RoleRead: """ Update role by role_id """ - return await update_role(request, role_id, role_object, current_user) + return await update_role(request, db_session, role_object, current_user) @router.delete("/{role_id}") -async def api_delete_role(request: Request, role_id: str, current_user: PublicUser = Depends(get_current_user)): +async def api_delete_role( + request: Request, + role_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): """ Delete role by ID """ - return await delete_role(request, role_id, current_user) + return await delete_role(request, db_session, role_id, current_user) diff --git a/apps/api/src/routers/trail.py b/apps/api/src/routers/trail.py index f2eb38b5..cb9b3d48 100644 --- a/apps/api/src/routers/trail.py +++ b/apps/api/src/routers/trail.py @@ -1,56 +1,97 @@ from fastapi import APIRouter, Depends, Request +from src.core.events.database import get_db_session +from src.db.trails import TrailCreate, TrailRead from src.security.auth import get_current_user -from src.services.trail.trail import Trail, add_activity_to_trail, add_course_to_trail, create_trail, get_user_trail_with_orgslug, get_user_trail, remove_course_from_trail +from src.services.trail.trail import ( + Trail, + add_activity_to_trail, + add_course_to_trail, + create_user_trail, + get_user_trails, + get_user_trail_with_orgid, + remove_course_from_trail, +) router = APIRouter() @router.post("/start") -async def api_start_trail(request: Request, trail_object: Trail, org_id: str, user=Depends(get_current_user)) -> Trail: +async def api_start_trail( + request: Request, + trail_object: TrailCreate, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> Trail: """ - Start trail + Start trail """ - return await create_trail(request, user, org_id, trail_object) + return await create_user_trail(request, user, trail_object, db_session) -@router.get("/org_id/{org_id}/trail") -async def api_get_trail_by_orgid(request: Request, org_slug: str, user=Depends(get_current_user)): +@router.get("/") +async def api_get_user_trail( + request: Request, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: """ Get a user trails """ - return await get_user_trail(request, user=user, org_slug=org_slug) + return await get_user_trails(request, user=user, db_session=db_session) -@router.get("/org_slug/{org_slug}/trail") -async def api_get_trail_by_orgslug(request: Request, org_slug: str, user=Depends(get_current_user)): +@router.get("/org/{org_id}/trail") +async def api_get_trail_by_org_id( + request: Request, + org_id: int, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: """ Get a user trails using org slug """ - return await get_user_trail_with_orgslug(request, user, org_slug=org_slug) - -# Courses in trail + return await get_user_trail_with_orgid( + request, user, org_id=org_id, db_session=db_session + ) -@router.post("/org_slug/{org_slug}/add_course/{course_id}") -async def api_add_course_to_trail(request: Request, course_id: str, org_slug: str, user=Depends(get_current_user)): +@router.post("/add_course/{course_uuid}") +async def api_add_course_to_trail( + request: Request, + course_uuid: str, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: """ Add Course to trail """ - return await add_course_to_trail(request, user, org_slug, course_id) + return await add_course_to_trail(request, user, course_uuid, db_session) -@router.post("/org_slug/{org_slug}/remove_course/{course_id}") -async def api_remove_course_to_trail(request: Request, course_id: str, org_slug: str, user=Depends(get_current_user)): +@router.delete("/remove_course/{course_uuid}") +async def api_remove_course_to_trail( + request: Request, + course_uuid: str, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: """ Remove Course from trail """ - return await remove_course_from_trail(request, user, org_slug, course_id) + return await remove_course_from_trail(request, user, course_uuid, db_session) -@router.post("/org_slug/{org_slug}/add_activity/course_id/{course_id}/activity_id/{activity_id}") -async def api_add_activity_to_trail(request: Request, activity_id: str, course_id: str, org_slug: str, user=Depends(get_current_user)): +@router.post("/add_activity/{activity_uuid}") +async def api_add_activity_to_trail( + request: Request, + activity_uuid: str, + user=Depends(get_current_user), + db_session=Depends(get_db_session), +) -> TrailRead: """ Add Course to trail """ - return await add_activity_to_trail(request, user, course_id, org_slug, activity_id) + return await add_activity_to_trail( + request, user, activity_uuid, db_session + ) diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 3b4edd1a..71ec2741 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,10 +1,27 @@ -from fastapi import Depends, APIRouter, Request +from typing import Literal +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session from src.security.auth import get_current_user -from src.services.users.schemas.users import PasswordChangeForm, PublicUser, User, UserWithPassword -from src.services.users.users import create_user, delete_user, get_profile_metadata, get_user_by_userid, update_user, update_user_password - - +from src.core.events.database import get_db_session +from src.db.users import ( + PublicUser, + User, + UserCreate, + UserRead, + UserUpdate, + UserUpdatePassword, +) +from src.services.users.users import ( + authorize_user_action, + create_user, + create_user_without_org, + delete_user_by_id, + read_user_by_id, + read_user_by_uuid, + update_user, + update_user_password, +) router = APIRouter() @@ -17,50 +34,119 @@ async def api_get_current_user(current_user: User = Depends(get_current_user)): """ return current_user.dict() -@router.get("/profile_metadata") -async def api_get_current_user_metadata(request: Request,current_user: User = Depends(get_current_user)): + +@router.get("/authorize/ressource/{ressource_uuid}/action/{action}") +async def api_get_authorization_status( + request: Request, + ressource_uuid: str, + action: Literal["create", "read", "update", "delete"], + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): """ - Get current user + Get current user authorization status """ - return await get_profile_metadata(request , current_user.dict()) + return await authorize_user_action( + request, db_session, current_user, ressource_uuid, action + ) - -@router.get("/user_id/{user_id}") -async def api_get_user_by_userid(request: Request,user_id: str): +@router.post("/{org_id}", response_model=UserRead, tags=["users"]) +async def api_create_user_with_orgid( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_object: UserCreate, + org_id: int, +) -> UserRead: """ - Get single user by user_id + Create User with Org ID """ - return await get_user_by_userid(request, user_id) + return await create_user(request, db_session, current_user, user_object, org_id) -@router.post("/") -async def api_create_user(request: Request,user_object: UserWithPassword, org_slug: str ): +@router.post("/", response_model=UserRead, tags=["users"]) +async def api_create_user_without_org( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_object: UserCreate, +) -> UserRead: """ - Create new user + Create User """ - return await create_user(request, None, user_object, org_slug) + return await create_user_without_org(request, db_session, current_user, user_object) -@router.delete("/user_id/{user_id}") -async def api_delete_user(request: Request, user_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/id/{user_id}", response_model=UserRead, tags=["users"]) +async def api_get_user_by_id( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_id: int, +) -> UserRead: """ - Delete user by ID + Get User by ID """ - - return await delete_user(request, current_user, user_id) + return await read_user_by_id(request, db_session, current_user, user_id) -@router.put("/user_id/{user_id}") -async def api_update_user(request: Request, user_object: User, user_id: str, current_user: PublicUser = Depends(get_current_user)): +@router.get("/uuid/{user_uuid}", response_model=UserRead, tags=["users"]) +async def api_get_user_by_uuid( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_uuid: str, +) -> UserRead: """ - Update user by ID + Get User by UUID """ - return await update_user(request, user_id, user_object, current_user) + return await read_user_by_uuid(request, db_session, current_user, user_uuid) -@router.put("/password/user_id/{user_id}") -async def api_update_user_password(request: Request, user_id: str , passwordChangeForm : PasswordChangeForm, current_user: PublicUser = Depends(get_current_user)): + +@router.put("/{user_id}", response_model=UserRead, tags=["users"]) +async def api_update_user( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_id: int, + user_object: UserUpdate, +) -> UserRead: """ - Update user password by ID + Update User """ - return await update_user_password(request,current_user, user_id, passwordChangeForm) + return await update_user(request, db_session, user_id, current_user, user_object) + + +@router.put("/change_password/{user_id}", response_model=UserRead, tags=["users"]) +async def api_update_user_password( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_id: int, + form: UserUpdatePassword, +) -> UserRead: + """ + Update User Password + """ + return await update_user_password(request, db_session, current_user, user_id, form) + + +@router.delete("/user_id/{user_id}", tags=["users"]) +async def api_delete_user( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_id: int, +): + """ + Delete User + """ + return await delete_user_by_id(request, db_session, current_user, user_id) diff --git a/apps/api/src/security/auth.py b/apps/api/src/security/auth.py index d125c15d..4d6d290a 100644 --- a/apps/api/src/security/auth.py +++ b/apps/api/src/security/auth.py @@ -1,3 +1,7 @@ +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.db.users import AnonymousUser, PublicUser, User, UserRead +from src.services.users.users import security_get_user from config.config import get_learnhouse_config from pydantic import BaseModel from fastapi import Depends, HTTPException, Request, status @@ -5,8 +9,7 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from datetime import datetime, timedelta from src.services.dev.dev import isDevModeEnabled -from src.services.users.schemas.users import AnonymousUser, PublicUser -from src.services.users.users import security_get_user, security_verify_password +from src.services.users.users import security_verify_password from src.security.security import ALGORITHM, SECRET_KEY from fastapi_jwt_auth import AuthJWT @@ -45,10 +48,13 @@ class TokenData(BaseModel): #### Classes #################################################### - - -async def authenticate_user(request: Request, email: str, password: str): - user = await security_get_user(request, email) +async def authenticate_user( + request: Request, + email: str, + password: str, + db_session: Session, +) -> User | bool: + user = await security_get_user(request, db_session, email) if not user: return False if not await security_verify_password(password, user.password): @@ -67,7 +73,11 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt -async def get_current_user(request: Request, Authorize: AuthJWT = Depends()): +async def get_current_user( + request: Request, + Authorize: AuthJWT = Depends(), + db_session: Session = Depends(get_db_session), +): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -81,7 +91,7 @@ async def get_current_user(request: Request, Authorize: AuthJWT = Depends()): except JWTError: raise credentials_exception if username: - user = await security_get_user(request, email=token_data.username) # type: ignore # treated as an email + user = await security_get_user(request, db_session, email=token_data.username) # type: ignore # treated as an email if user is None: raise credentials_exception return PublicUser(**user.dict()) @@ -89,6 +99,6 @@ async def get_current_user(request: Request, Authorize: AuthJWT = Depends()): return AnonymousUser() -async def non_public_endpoint(current_user: PublicUser): +async def non_public_endpoint(current_user: UserRead | AnonymousUser): if isinstance(current_user, AnonymousUser): raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index ea2e1838..3d6872b5 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -1,29 +1,31 @@ from typing import Literal from fastapi import HTTPException, status, Request -from src.security.rbac.utils import check_element_type, get_id_identifier_of_element -from src.services.roles.schemas.roles import RoleInDB -from src.services.users.schemas.users import UserRolesInOrganization +from sqlalchemy import null +from sqlmodel import Session, select +from src.db.collections import Collection +from src.db.courses import Course +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.roles import Role +from src.db.user_organizations import UserOrganization +from src.security.rbac.utils import check_element_type +# Tested and working async def authorization_verify_if_element_is_public( request, - element_id: str, - user_id: str, + element_uuid: str, action: Literal["read"], -): - element_nature = await check_element_type(element_id) - + db_session: Session, +): + element_nature = await check_element_type(element_uuid) # Verifies if the element is public - if ( - element_nature == ("courses" or "collections") - and action == "read" - and user_id == "anonymous" - ): + if element_nature == ("courses" or "collections") and action == "read": if element_nature == "courses": - courses = request.app.db["courses"] - course = await courses.find_one({"course_id": element_id}) - - if course["public"]: + statement = select(Course).where( + Course.public is True, Course.course_uuid == element_uuid + ) + course = db_session.exec(statement).first() + if course: return True else: raise HTTPException( @@ -32,10 +34,12 @@ async def authorization_verify_if_element_is_public( ) if element_nature == "collections": - collections = request.app.db["collections"] - collection = await collections.find_one({"collection_id": element_id}) + statement = select(Collection).where( + Collection.public is True, Collection.collection_uuid == element_uuid + ) + collection = db_session.exec(statement).first() - if collection["public"]: + if collection: return True else: raise HTTPException( @@ -49,87 +53,81 @@ async def authorization_verify_if_element_is_public( ) +# Tested and working async def authorization_verify_if_user_is_author( request, - user_id: str, + user_id: int, action: Literal["read", "update", "delete", "create"], - element_id: str, + element_uuid: str, + db_session: Session, ): if action == "update" or "delete" or "read": - element_nature = await check_element_type(element_id) - elements = request.app.db[element_nature] - element_identifier = await get_id_identifier_of_element(element_id) - element = await elements.find_one({element_identifier: element_id}) - if user_id in element["authors"]: - return True + statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == element_uuid + ) + resource_author = db_session.exec(statement).first() + + if resource_author: + if resource_author.user_id == int(user_id): + if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( + resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER + ): + return True + else: + return False + else: + return False else: return False + + +# Tested and working +async def authorization_verify_based_on_roles( + request: Request, + user_id: int, + action: Literal["read", "update", "delete", "create"], + element_uuid: str, + db_session: Session, +): + element_type = await check_element_type(element_uuid) + + # Get user roles bound to an organization and standard roles + statement = ( + select(Role) + .join(UserOrganization) + .where((UserOrganization.org_id == Role.org_id) | (Role.org_id == null())) + .where(UserOrganization.user_id == user_id) + ) + + user_roles_in_organization_and_standard_roles = db_session.exec(statement).all() + + # Find in roles list if there is a role that matches users action for this type of element + for role in user_roles_in_organization_and_standard_roles: + role = Role.from_orm(role) + if role.rights: + rights = role.rights + if rights[element_type][f"action_{action}"] is True: + return True + else: + return False else: return False -async def authorization_verify_based_on_roles( - request: Request, - user_id: str, - action: Literal["read", "update", "delete", "create"], - roles_list: list[UserRolesInOrganization], - element_id: str, -): - element_type = await check_element_type(element_id) - element = request.app.db[element_type] - roles = request.app.db["roles"] - - # Get the element - element_identifier = await get_id_identifier_of_element(element_id) - element = await element.find_one({element_identifier: element_id}) - - # Get the roles of the user - roles_id_list = [role["role_id"] for role in roles_list] - roles = await roles.find({"role_id": {"$in": roles_id_list}}).to_list(length=100) - - async def checkRoles(): - # Check Roles - for role in roles: - role = RoleInDB(**role) - if role.elements[element_type][f"action_{action}"] is True: - return True - else: - return False - - async def checkOrgRoles(): - # Check Org Roles - users = request.app.db["users"] - user = await users.find_one({"user_id": user_id}) - if element is not None: - for org in user["orgs"]: - if org["org_id"] == element["org_id"]: - if org["org_role"] == "owner" or org["org_role"] == "editor": - return True - else: - return False - - if await checkRoles() or await checkOrgRoles(): - return True - else: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User rights (roless) : You don't have the right to perform this action", - ) - - +# Tested and working async def authorization_verify_based_on_roles_and_authorship( request: Request, - user_id: str, + user_id: int, action: Literal["read", "update", "delete", "create"], - roles_list: list[UserRolesInOrganization], - element_id: str, + element_uuid: str, + db_session: Session, ): isAuthor = await authorization_verify_if_user_is_author( - request, user_id, action, element_id + request, user_id, action, element_uuid, db_session ) isRole = await authorization_verify_based_on_roles( - request, user_id, action, roles_list, element_id + request, user_id, action, element_uuid, db_session ) if isAuthor or isRole: @@ -141,8 +139,8 @@ async def authorization_verify_based_on_roles_and_authorship( ) -async def authorization_verify_if_user_is_anon(user_id: str): - if user_id == "anonymous": +async def authorization_verify_if_user_is_anon(user_id: int): + if user_id == 0: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You should be logged in to perform this action", diff --git a/apps/api/src/security/rbac/utils.py b/apps/api/src/security/rbac/utils.py index 3ef48a09..51835ee9 100644 --- a/apps/api/src/security/rbac/utils.py +++ b/apps/api/src/security/rbac/utils.py @@ -5,6 +5,7 @@ async def check_element_type(element_id): """ Check if the element is a course, a user, a house or a collection, by checking its prefix """ + print("element_id", element_id) if element_id.startswith("course_"): return "courses" elif element_id.startswith("user_"): @@ -13,12 +14,14 @@ async def check_element_type(element_id): return "houses" elif element_id.startswith("org_"): return "organizations" - elif element_id.startswith("coursechapter_"): + elif element_id.startswith("chapter_"): return "coursechapters" elif element_id.startswith("collection_"): return "collections" elif element_id.startswith("activity_"): return "activities" + elif element_id.startswith("role_"): + return "roles" else: raise HTTPException( status_code=status.HTTP_409_CONFLICT, diff --git a/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py b/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py new file mode 100644 index 00000000..f32e69e9 --- /dev/null +++ b/apps/api/src/services/blocks/block_types/imageBlock/imageBlock.py @@ -0,0 +1,89 @@ +from datetime import datetime +from uuid import uuid4 +from src.db.organizations import Organization +from fastapi import HTTPException, status, UploadFile, Request +from sqlmodel import Session, select +from src.db.activities import Activity +from src.db.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses import Course +from src.services.blocks.utils.upload_files import upload_file_and_return_file_object +from src.services.users.users import PublicUser + + +async def create_image_block( + request: Request, image_file: UploadFile, activity_uuid: str, db_session: Session +): + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found" + ) + + block_type = "imageBlock" + + # get org_uuid + statement = select(Organization).where(Organization.id == activity.org_id) + org = db_session.exec(statement).first() + + # get course + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) + + # get block id + block_uuid = str(f"block_{uuid4()}") + + block_data = await upload_file_and_return_file_object( + request, + image_file, + activity_uuid, + block_uuid, + ["jpg", "jpeg", "png", "gif"], + block_type, + org.org_uuid, + str(course.course_uuid), + ) + + # create block + block = Block( + activity_id=activity.id if activity.id else 0, + block_type=BlockTypeEnum.BLOCK_IMAGE, + content=block_data.dict(), + org_id=org.id if org.id else 0, + course_id=course.id if course.id else 0, + block_uuid=block_uuid, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + # insert block + db_session.add(block) + db_session.commit() + db_session.refresh(block) + + block = BlockRead.from_orm(block) + + return block + + +async def get_image_block( + request: Request, block_uuid: str, current_user: PublicUser, db_session: Session +): + statement = select(Block).where(Block.block_uuid == block_uuid) + block = db_session.exec(statement).first() + + if block: + + block = BlockRead.from_orm(block) + + return block + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Image block does not exist" + ) diff --git a/apps/api/src/services/blocks/block_types/imageBlock/images.py b/apps/api/src/services/blocks/block_types/imageBlock/images.py deleted file mode 100644 index 0aa16e92..00000000 --- a/apps/api/src/services/blocks/block_types/imageBlock/images.py +++ /dev/null @@ -1,72 +0,0 @@ -from uuid import uuid4 -from fastapi import HTTPException, status, UploadFile, Request -from src.services.blocks.schemas.blocks import Block -from src.services.blocks.utils.upload_files import upload_file_and_return_file_object - -from src.services.users.users import PublicUser - - -async def create_image_block( - request: Request, image_file: UploadFile, activity_id: str -): - blocks = request.app.db["blocks"] - activity = request.app.db["activities"] - courses = request.app.db["courses"] - - block_type = "imageBlock" - - # get org_id from activity - activity = await activity.find_one({"activity_id": activity_id}, {"_id": 0}) - org_id = activity["org_id"] - - coursechapter_id = activity["coursechapter_id"] - - # get course_id from coursechapter - course = await courses.find_one( - {"chapters": coursechapter_id}, - {"_id": 0}, - ) - - - # get block id - block_id = str(f"block_{uuid4()}") - - block_data = await upload_file_and_return_file_object( - request, - image_file, - activity_id, - block_id, - ["jpg", "jpeg", "png", "gif"], - block_type, - org_id, - course["course_id"], - ) - - # create block - block = Block( - block_id=block_id, - activity_id=activity_id, - block_type=block_type, - block_data=block_data, - org_id=org_id, - course_id=course["course_id"], - ) - - # insert block - await blocks.insert_one(block.dict()) - - return block - - -async def get_image_block(request: Request, file_id: str, current_user: PublicUser): - blocks = request.app.db["blocks"] - - video_block = await blocks.find_one({"block_id": file_id}) - - if video_block: - return Block(**video_block) - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Image block does not exist" - ) diff --git a/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py b/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py index 00de83b1..48c06a20 100644 --- a/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py +++ b/apps/api/src/services/blocks/block_types/pdfBlock/pdfBlock.py @@ -1,69 +1,89 @@ +from datetime import datetime from uuid import uuid4 +from src.db.organizations import Organization from fastapi import HTTPException, status, UploadFile, Request -from src.services.blocks.schemas.blocks import Block +from sqlmodel import Session, select +from src.db.activities import Activity +from src.db.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses import Course from src.services.blocks.utils.upload_files import upload_file_and_return_file_object from src.services.users.users import PublicUser -async def create_pdf_block(request: Request, pdf_file: UploadFile, activity_id: str): - blocks = request.app.db["blocks"] - activity = request.app.db["activities"] - courses = request.app.db["courses"] +async def create_pdf_block( + request: Request, pdf_file: UploadFile, activity_uuid: str, db_session: Session +): + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found" + ) block_type = "pdfBlock" - # get org_id from activity - activity = await activity.find_one({"activity_id": activity_id}, {"_id": 0}) - org_id = activity["org_id"] + # get org_uuid + statement = select(Organization).where(Organization.id == activity.org_id) + org = db_session.exec(statement).first() + + # get course + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) # get block id - block_id = str(f"block_{uuid4()}") - - coursechapter_id = activity["coursechapter_id"] - - # get course_id from coursechapter - course = await courses.find_one( - {"chapters": coursechapter_id}, - {"_id": 0}, - ) + block_uuid = str(f"block_{uuid4()}") block_data = await upload_file_and_return_file_object( request, pdf_file, - activity_id, - block_id, + activity_uuid, + block_uuid, ["pdf"], block_type, - org_id, - course["course_id"], + org.org_uuid, + str(course.course_uuid), ) # create block block = Block( - block_id=block_id, - activity_id=activity_id, - block_type=block_type, - block_data=block_data, - org_id=org_id, - course_id=course["course_id"], + activity_id=activity.id if activity.id else 0, + block_type=BlockTypeEnum.BLOCK_DOCUMENT_PDF, + content=block_data.dict(), + org_id=org.id if org.id else 0, + course_id=course.id if course.id else 0, + block_uuid=block_uuid, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) # insert block - await blocks.insert_one(block.dict()) + db_session.add(block) + db_session.commit() + db_session.refresh(block) + + block = BlockRead.from_orm(block) return block -async def get_pdf_block(request: Request, file_id: str, current_user: PublicUser): - blocks = request.app.db["blocks"] +async def get_pdf_block( + request: Request, block_uuid: str, current_user: PublicUser, db_session: Session +): + statement = select(Block).where(Block.block_uuid == block_uuid) + block = db_session.exec(statement).first() - pdf_block = await blocks.find_one({"block_id": file_id}) - - if pdf_block: - return Block(**pdf_block) - - else: + if not block: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Video file does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail="Video file does not exist" ) + + block = BlockRead.from_orm(block) + + return block diff --git a/apps/api/src/services/blocks/block_types/quizBlock/quizBlock.py b/apps/api/src/services/blocks/block_types/quizBlock/quizBlock.py deleted file mode 100644 index f8758516..00000000 --- a/apps/api/src/services/blocks/block_types/quizBlock/quizBlock.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import List, Literal -from uuid import uuid4 -from fastapi import Request -from pydantic import BaseModel -from src.services.blocks.schemas.blocks import Block -from src.services.users.users import PublicUser - - -class option(BaseModel): - option_id: str - option_type: Literal["text", "image"] - option_data: str - - -class answer(BaseModel): - question_id: str - option_id: str - - -class question(BaseModel): - question_id: str - question_value:str - options: List[option] - - -class quizBlock(BaseModel): - questions: List[question] - answers: List[answer] - - -async def create_quiz_block(request: Request, quizBlock: quizBlock, activity_id: str, user: PublicUser): - blocks = request.app.db["blocks"] - activities = request.app.db["activities"] - request.app.db["courses"] - - # Get org_id from activity - activity = await activities.find_one({"activity_id": activity_id}, {"_id": 0, "org_id": 1}) - org_id = activity["org_id"] - - # Get course_id from activity - course = await activities.find_one({"activity_id": activity_id}, {"_id": 0, "course_id": 1}) - - block_id = str(f"block_{uuid4()}") - - # create block - block = Block(block_id=block_id, activity_id=activity_id, - block_type="quizBlock", block_data=quizBlock, org_id=org_id, course_id=course["course_id"]) - - # insert block - await blocks.insert_one(block.dict()) - - return block - - -async def get_quiz_block_options(request: Request, block_id: str, user: PublicUser): - blocks = request.app.db["blocks"] - # find block but get only the options - block = await blocks.find_one({"block_id": block_id, }, { - "_id": 0, "block_data.answers": 0}) - - return block - -async def get_quiz_block_answers(request: Request, block_id: str, user: PublicUser): - blocks = request.app.db["blocks"] - - # find block but get only the answers - block = await blocks.find_one({"block_id": block_id, }, { - "_id": 0, "block_data.questions": 0}) - - return block diff --git a/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py b/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py index 0c7c2282..ab6735d7 100644 --- a/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py +++ b/apps/api/src/services/blocks/block_types/videoBlock/videoBlock.py @@ -1,73 +1,89 @@ +from datetime import datetime from uuid import uuid4 +from src.db.organizations import Organization from fastapi import HTTPException, status, UploadFile, Request -from src.services.blocks.schemas.blocks import Block +from sqlmodel import Session, select +from src.db.activities import Activity +from src.db.blocks import Block, BlockRead, BlockTypeEnum +from src.db.courses import Course from src.services.blocks.utils.upload_files import upload_file_and_return_file_object from src.services.users.users import PublicUser async def create_video_block( - request: Request, video_file: UploadFile, activity_id: str + request: Request, video_file: UploadFile, activity_uuid: str, db_session: Session ): - blocks = request.app.db["blocks"] - activity = request.app.db["activities"] - courses = request.app.db["courses"] + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found" + ) block_type = "videoBlock" - # get org_id from activity - activity = await activity.find_one( - {"activity_id": activity_id}, {"_id": 0} - ) - org_id = activity["org_id"] + # get org_uuid + statement = select(Organization).where(Organization.id == activity.org_id) + org = db_session.exec(statement).first() + + # get course + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) # get block id - block_id = str(f"block_{uuid4()}") - - coursechapter_id = activity["coursechapter_id"] - - # get course_id from coursechapter - course = await courses.find_one( - {"chapters": coursechapter_id}, - {"_id": 0}, - ) + block_uuid = str(f"block_{uuid4()}") block_data = await upload_file_and_return_file_object( request, video_file, - activity_id, - block_id, + activity_uuid, + block_uuid, ["mp4", "webm", "ogg"], block_type, - org_id, - course["course_id"], + org.org_uuid, + str(course.course_uuid), ) # create block block = Block( - block_id=block_id, - activity_id=activity_id, - block_type=block_type, - block_data=block_data, - org_id=org_id, - course_id=course["course_id"], + activity_id=activity.id if activity.id else 0, + block_type=BlockTypeEnum.BLOCK_VIDEO, + content=block_data.dict(), + org_id=org.id if org.id else 0, + course_id=course.id if course.id else 0, + block_uuid=block_uuid, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) # insert block - await blocks.insert_one(block.dict()) + db_session.add(block) + db_session.commit() + db_session.refresh(block) + + block = BlockRead.from_orm(block) return block -async def get_video_block(request: Request, file_id: str, current_user: PublicUser): - blocks = request.app.db["blocks"] +async def get_video_block( + request: Request, block_uuid: str, current_user: PublicUser, db_session: Session +): + statement = select(Block).where(Block.block_uuid == block_uuid) + block = db_session.exec(statement).first() - video_block = await blocks.find_one({"block_id": file_id}) - - if video_block: - return Block(**video_block) - - else: + if not block: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Video file does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail="Video file does not exist" ) + + block = BlockRead.from_orm(block) + + return block diff --git a/apps/api/src/services/blocks/schemas/files.py b/apps/api/src/services/blocks/schemas/files.py index c66edf34..dcbef83f 100644 --- a/apps/api/src/services/blocks/schemas/files.py +++ b/apps/api/src/services/blocks/schemas/files.py @@ -7,4 +7,4 @@ class BlockFile(BaseModel): file_name: str file_size: int file_type: str - activity_id: str \ No newline at end of file + activity_uuid: str \ No newline at end of file diff --git a/apps/api/src/services/blocks/utils/upload_files.py b/apps/api/src/services/blocks/utils/upload_files.py index a3ae2096..76ebd1c7 100644 --- a/apps/api/src/services/blocks/utils/upload_files.py +++ b/apps/api/src/services/blocks/utils/upload_files.py @@ -7,12 +7,12 @@ from src.services.utils.upload_content import upload_content async def upload_file_and_return_file_object( request: Request, file: UploadFile, - activity_id: str, + activity_uuid: str, block_id: str, list_of_allowed_file_formats: list, type_of_block: str, - org_id: str, - course_id: str, + org_uuid: str, + course_uuid: str, ): # get file id file_id = str(uuid.uuid4()) @@ -45,12 +45,12 @@ async def upload_file_and_return_file_object( file_name=file_name, file_size=file_size, file_type=file_type, - activity_id=activity_id, + activity_uuid=activity_uuid, ) await upload_content( - f"courses/{course_id}/activities/{activity_id}/dynamic/blocks/{type_of_block}/{block_id}", - org_id=org_id, + f"courses/{course_uuid}/activities/{activity_uuid}/dynamic/blocks/{type_of_block}/{block_id}", + org_uuid=org_uuid, file_binary=file_binary, file_and_format=f"{file_id}.{file_format}", ) diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index 44ee1779..0acae6b3 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -1,35 +1,17 @@ from typing import Literal -from pydantic import BaseModel +from sqlmodel import Session, select +from src.db.chapters import Chapter from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, - authorization_verify_if_element_is_public, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) -from src.services.users.schemas.users import AnonymousUser, PublicUser -from fastapi import HTTPException, status, Request +from src.db.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate +from src.db.chapter_activities import ChapterActivity +from src.db.users import AnonymousUser, PublicUser +from fastapi import HTTPException, Request from uuid import uuid4 from datetime import datetime -#### Classes #################################################### - - -class Activity(BaseModel): - name: str - type: str - content: object - - -class ActivityInDB(Activity): - activity_id: str - course_id: str - coursechapter_id: str - org_id: str - creationDate: str - updateDate: str - - -#### Classes #################################################### - #################################################### # CRUD @@ -38,148 +20,162 @@ class ActivityInDB(Activity): async def create_activity( request: Request, - activity_object: Activity, - org_id: str, - coursechapter_id: str, - current_user: PublicUser, + activity_object: ActivityCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): - activities = request.app.db["activities"] - courses = request.app.db["courses"] - users = request.app.db["users"] + activity = Activity.from_orm(activity_object) - # get user - user = await users.find_one({"user_id": current_user.user_id}) + # CHeck if org exists + statement = select(Chapter).where(Chapter.id == activity_object.chapter_id) + chapter = db_session.exec(statement).first() - # generate activity_id - activity_id = str(f"activity_{uuid4()}") + if not chapter: + raise HTTPException( + status_code=404, + detail="Chapter not found", + ) - # verify activity rights - await authorization_verify_based_on_roles( - request, - current_user.user_id, - "create", - user["roles"], - activity_id, + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "create", db_session) + + activity.activity_uuid = str(f"activity_{uuid4()}") + activity.creation_date = str(datetime.now()) + activity.update_date = str(datetime.now()) + activity.org_id = chapter.org_id + activity.course_id = chapter.course_id + + # Insert Activity in DB + db_session.add(activity) + db_session.commit() + db_session.refresh(activity) + + # Find the last activity in the Chapter and add it to the list + statement = ( + select(ChapterActivity) + .where(ChapterActivity.chapter_id == activity_object.chapter_id) + .order_by(ChapterActivity.order) + ) + chapter_activities = db_session.exec(statement).all() + + last_order = chapter_activities[-1].order if chapter_activities else 0 + to_be_used_order = last_order + 1 + + # Add activity to chapter + activity_chapter = ChapterActivity( + chapter_id=activity_object.chapter_id, + activity_id=activity.id if activity.id else 0, + course_id=chapter.course_id, + org_id=chapter.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=to_be_used_order, ) - # get course_id from activity - course = await courses.find_one({"chapters": coursechapter_id}) + # Insert ChapterActivity link in DB + db_session.add(activity_chapter) + db_session.commit() + db_session.refresh(activity_chapter) - # create activity - activity = ActivityInDB( - **activity_object.dict(), - creationDate=str(datetime.now()), - coursechapter_id=coursechapter_id, - updateDate=str(datetime.now()), - activity_id=activity_id, - org_id=org_id, - course_id=course["course_id"], - ) - await activities.insert_one(activity.dict()) - - # update chapter - await courses.update_one( - {"chapters_content.coursechapter_id": coursechapter_id}, - {"$addToSet": {"chapters_content.$.activities": activity_id}}, - ) - - return activity + return ActivityRead.from_orm(activity) -async def get_activity(request: Request, activity_id: str, current_user: PublicUser): - activities = request.app.db["activities"] - courses = request.app.db["courses"] - - activity = await activities.find_one({"activity_id": activity_id}) - - # get course_id from activity - coursechapter_id = activity["coursechapter_id"] - await courses.find_one({"chapters": coursechapter_id}) - - # verify course rights - await verify_rights(request, activity["course_id"], current_user, "read") +async def get_activity( + request: Request, + activity_uuid: str, + current_user: PublicUser, + db_session: Session, +): + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() if not activity: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + status_code=404, + detail="Activity not found", ) - activity = ActivityInDB(**activity) + # RBAC check + await rbac_check(request, activity.activity_uuid, current_user, "read", db_session) + + activity = ActivityRead.from_orm(activity) + return activity async def update_activity( request: Request, - activity_object: Activity, - activity_id: str, - current_user: PublicUser, + activity_object: ActivityUpdate, + activity_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): - activities = request.app.db["activities"] - - activity = await activities.find_one({"activity_id": activity_id}) - - # verify course rights - await verify_rights(request, activity_id, current_user, "update") - - if activity: - creationDate = activity["creationDate"] - - # get today's date - datetime_object = datetime.now() - - updated_course = ActivityInDB( - activity_id=activity_id, - coursechapter_id=activity["coursechapter_id"], - creationDate=creationDate, - updateDate=str(datetime_object), - course_id=activity["course_id"], - org_id=activity["org_id"], - **activity_object.dict(), - ) - - await activities.update_one( - {"activity_id": activity_id}, {"$set": updated_course.dict()} - ) - - return ActivityInDB(**updated_course.dict()) - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="activity does not exist" - ) - - -async def delete_activity(request: Request, activity_id: str, current_user: PublicUser): - activities = request.app.db["activities"] - - activity = await activities.find_one({"activity_id": activity_id}) - - # verify course rights - await verify_rights(request, activity_id, current_user, "delete") + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() if not activity: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="activity does not exist" + status_code=404, + detail="Activity not found", ) - # Remove Activity - isDeleted = await activities.delete_one({"activity_id": activity_id}) - - # Remove Activity from chapter - courses = request.app.db["courses"] - isDeletedFromChapter = await courses.update_one( - {"chapters_content.activities": activity_id}, - {"$pull": {"chapters_content.$.activities": activity_id}}, + # RBAC check + await rbac_check( + request, activity.activity_uuid, current_user, "update", db_session ) - if isDeleted and isDeletedFromChapter: - return {"detail": "Activity deleted"} - else: + # Update only the fields that were passed in + for var, value in vars(activity_object).items(): + if value is not None: + setattr(activity, var, value) + + db_session.add(activity) + db_session.commit() + db_session.refresh(activity) + + activity = ActivityRead.from_orm(activity) + + return activity + + +async def delete_activity( + request: Request, + activity_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() + + if not activity: raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", + status_code=404, + detail="Activity not found", ) + # RBAC check + await rbac_check( + request, activity.activity_uuid, current_user, "delete", db_session + ) + + # Delete activity from chapter + statement = select(ChapterActivity).where( + ChapterActivity.activity_id == activity.id + ) + activity_chapter = db_session.exec(statement).first() + + if not activity_chapter: + raise HTTPException( + status_code=404, + detail="Activity not found in chapter", + ) + + db_session.delete(activity_chapter) + db_session.delete(activity) + db_session.commit() + + return {"detail": "Activity deleted"} + #################################################### # Misc @@ -187,64 +183,49 @@ async def delete_activity(request: Request, activity_id: str, current_user: Publ async def get_activities( - request: Request, coursechapter_id: str, current_user: PublicUser -): - activities = request.app.db["activities"] - - activities = activities.find({"coursechapter_id": coursechapter_id}) + request: Request, + coursechapter_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[ActivityRead]: + statement = select(ChapterActivity).where( + ChapterActivity.chapter_id == coursechapter_id + ) + activities = db_session.exec(statement).all() if not activities: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + status_code=404, + detail="No activities found", ) - activities = [ - ActivityInDB(**activity) for activity in await activities.to_list(length=100) - ] + # RBAC check + await rbac_check(request, "activity_x", current_user, "read", db_session) + + activities = [ActivityRead.from_orm(activity) for activity in activities] return activities -#### Security #################################################### +## 🔒 RBAC Utils ## -async def verify_rights( +async def rbac_check( request: Request, - activity_id: str, # course_id in case of read + course_id: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], + db_session: Session, ): - if action == "read": - if current_user.user_id == "anonymous": - await authorization_verify_if_element_is_public( - request, activity_id, current_user.user_id, action - ) - else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) + await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_if_user_is_anon(current_user.user_id) - - await authorization_verify_based_on_roles( - request, - current_user.user_id, - action, - user["roles"], - activity_id, - ) - else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) - - await authorization_verify_if_user_is_anon(current_user.user_id) - - await authorization_verify_based_on_roles( - request, - current_user.user_id, - action, - user["roles"], - activity_id, - ) + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index 8919639b..5a4d24f1 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -1,7 +1,22 @@ -from src.security.rbac.rbac import authorization_verify_based_on_roles +from typing import Literal +from src.db.courses import Course +from src.db.organizations import Organization +from sqlmodel import Session, select +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) +from src.db.chapters import Chapter +from src.db.activities import ( + Activity, + ActivityRead, + ActivitySubTypeEnum, + ActivityTypeEnum, +) +from src.db.chapter_activities import ChapterActivity +from src.db.course_chapters import CourseChapter +from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.pdfs import upload_pdf -from src.services.users.users import PublicUser -from src.services.courses.activities.activities import ActivityInDB from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 from datetime import datetime @@ -10,26 +25,46 @@ from datetime import datetime async def create_documentpdf_activity( request: Request, name: str, - coursechapter_id: str, - current_user: PublicUser, + chapter_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, pdf_file: UploadFile | None = None, ): - activities = request.app.db["activities"] - courses = request.app.db["courses"] - users = request.app.db["users"] + # RBAC check + await rbac_check(request, "activity_x", current_user, "create", db_session) - # get user - user = await users.find_one({"user_id": current_user.user_id}) + # get chapter_id + statement = select(Chapter).where(Chapter.id == chapter_id) + chapter = db_session.exec(statement).first() - # generate activity_id - activity_id = str(f"activity_{uuid4()}") + if not chapter: + raise HTTPException( + status_code=404, + detail="Chapter not found", + ) - # get org_id from course - coursechapter = await courses.find_one( - {"chapters_content.coursechapter_id": coursechapter_id} - ) + statement = select(CourseChapter).where(CourseChapter.chapter_id == chapter_id) + coursechapter = db_session.exec(statement).first() - org_id = coursechapter["org_id"] + if not coursechapter: + raise HTTPException( + status_code=404, + detail="CourseChapter not found", + ) + + # get org_id + org_id = coursechapter.org_id + + # Get org_uuid + statement = select(Organization).where(Organization.id == coursechapter.org_id) + organization = db_session.exec(statement).first() + + # Get course_uuid + statement = select(Course).where(Course.id == coursechapter.course_id) + course = db_session.exec(statement).first() + + # create activity uuid + activity_uuid = f"activity_{uuid4()}" # check if pdf_file is not None if not pdf_file: @@ -51,45 +86,77 @@ async def create_documentpdf_activity( status_code=status.HTTP_409_CONFLICT, detail="Pdf : No pdf file provided" ) - activity_object = ActivityInDB( - org_id=org_id, - activity_id=activity_id, - coursechapter_id=coursechapter_id, + # Create activity + activity = Activity( name=name, - type="documentpdf", - course_id=coursechapter["course_id"], + activity_type=ActivityTypeEnum.TYPE_DOCUMENT, + activity_sub_type=ActivitySubTypeEnum.SUBTYPE_DOCUMENT_PDF, content={ - "documentpdf": { - "filename": "documentpdf." + pdf_format, - "activity_id": activity_id, - } + "filename": "documentpdf." + pdf_format, + "activity_uuid": activity_uuid, }, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), + published_version=1, + version=1, + org_id=org_id if org_id else 0, + course_id=coursechapter.course_id, + activity_uuid=activity_uuid, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) - await authorization_verify_based_on_roles( - request, - current_user.user_id, - "create", - user["roles"], - activity_id, - ) + # Insert Activity in DB + db_session.add(activity) + db_session.commit() + db_session.refresh(activity) - # create activity - activity = ActivityInDB(**activity_object.dict()) - await activities.insert_one(activity.dict()) + # Add activity to chapter + activity_chapter = ChapterActivity( + chapter_id=(int(chapter_id)), + activity_id=activity.id, # type: ignore + course_id=coursechapter.course_id, + org_id=coursechapter.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=1, + ) # upload pdf if pdf_file: # get pdffile format - await upload_pdf(pdf_file, activity_id, org_id, coursechapter["course_id"]) + await upload_pdf( + pdf_file, + activity.activity_uuid, + organization.org_uuid, + course.course_uuid, + ) - # todo : choose whether to update the chapter or not - # update chapter - await courses.update_one( - {"chapters_content.coursechapter_id": coursechapter_id}, - {"$addToSet": {"chapters_content.$.activities": activity_id}}, + # Insert ChapterActivity link in DB + db_session.add(activity_chapter) + db_session.commit() + db_session.refresh(activity_chapter) + + return ActivityRead.from_orm(activity) + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, ) - return activity + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/uploads/pdfs.py b/apps/api/src/services/courses/activities/uploads/pdfs.py index fb4447b8..3d4f5ef6 100644 --- a/apps/api/src/services/courses/activities/uploads/pdfs.py +++ b/apps/api/src/services/courses/activities/uploads/pdfs.py @@ -1,15 +1,14 @@ - from src.services.utils.upload_content import upload_content -async def upload_pdf(pdf_file, activity_id, org_id, course_id): +async def upload_pdf(pdf_file, activity_uuid, org_uuid, course_uuid): contents = pdf_file.file.read() pdf_format = pdf_file.filename.split(".")[-1] try: await upload_content( - f"courses/{course_id}/activities/{activity_id}/documentpdf", - org_id, + f"courses/{course_uuid}/activities/{activity_uuid}/documentpdf", + org_uuid, contents, f"documentpdf.{pdf_format}", ) diff --git a/apps/api/src/services/courses/activities/uploads/videos.py b/apps/api/src/services/courses/activities/uploads/videos.py index d2aae465..2da6c35e 100644 --- a/apps/api/src/services/courses/activities/uploads/videos.py +++ b/apps/api/src/services/courses/activities/uploads/videos.py @@ -2,14 +2,14 @@ from src.services.utils.upload_content import upload_content -async def upload_video(video_file, activity_id, org_id, course_id): +async def upload_video(video_file, activity_uuid, org_uuid, course_uuid): contents = video_file.file.read() video_format = video_file.filename.split(".")[-1] try: await upload_content( - f"courses/{course_id}/activities/{activity_id}/video", - org_id, + f"courses/{course_uuid}/activities/{activity_uuid}/video", + org_uuid, contents, f"video.{video_format}", ) diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 470e4ddc..16bcf196 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,12 +1,24 @@ from typing import Literal +from src.db.courses import Course +from src.db.organizations import Organization from pydantic import BaseModel +from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, ) +from src.db.chapters import Chapter +from src.db.activities import ( + Activity, + ActivityRead, + ActivitySubTypeEnum, + ActivityTypeEnum, +) +from src.db.chapter_activities import ChapterActivity +from src.db.course_chapters import CourseChapter +from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.videos import upload_video -from src.services.users.users import PublicUser -from src.services.courses.activities.activities import ActivityInDB from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 from datetime import datetime @@ -15,32 +27,43 @@ from datetime import datetime async def create_video_activity( request: Request, name: str, - coursechapter_id: str, + chapter_id: str, current_user: PublicUser, + db_session: Session, video_file: UploadFile | None = None, ): - activities = request.app.db["activities"] - courses = request.app.db["courses"] - users = request.app.db["users"] + # RBAC check + await rbac_check(request, "activity_x", current_user, "create", db_session) - # get user - user = await users.find_one({"user_id": current_user.user_id}) + # get chapter_id + statement = select(Chapter).where(Chapter.id == chapter_id) + chapter = db_session.exec(statement).first() - # generate activity_id - activity_id = str(f"activity_{uuid4()}") + if not chapter: + raise HTTPException( + status_code=404, + detail="Chapter not found", + ) - # get org_id from course - coursechapter = await courses.find_one( - {"chapters_content.coursechapter_id": coursechapter_id} - ) + statement = select(CourseChapter).where(CourseChapter.chapter_id == chapter_id) + coursechapter = db_session.exec(statement).first() if not coursechapter: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="CourseChapter : No coursechapter found", + status_code=404, + detail="CourseChapter not found", ) - org_id = coursechapter["org_id"] + # Get org_uuid + statement = select(Organization).where(Organization.id == coursechapter.org_id) + organization = db_session.exec(statement).first() + + # Get course_uuid + statement = select(Course).where(Course.id == coursechapter.course_id) + course = db_session.exec(statement).first() + + # generate activity_uuid + activity_uuid = str(f"activity_{uuid4()}") # check if video_file is not None if not video_file: @@ -64,55 +87,63 @@ async def create_video_activity( detail="Video : No video file provided", ) - activity_object = ActivityInDB( - org_id=org_id, - activity_id=activity_id, - coursechapter_id=coursechapter_id, - course_id=coursechapter["course_id"], + activity_object = Activity( name=name, - type="video", + activity_type=ActivityTypeEnum.TYPE_VIDEO, + activity_sub_type=ActivitySubTypeEnum.SUBTYPE_VIDEO_HOSTED, + activity_uuid=activity_uuid, + org_id=coursechapter.org_id, + course_id=coursechapter.course_id, + published_version=1, content={ - "video": { - "filename": "video." + video_format, - "activity_id": activity_id, - } + "filename": "video." + video_format, + "activity_uuid": activity_uuid, }, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - ) - - await authorization_verify_based_on_roles( - request, - current_user.user_id, - "create", - user["roles"], - activity_id, + version=1, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) # create activity - activity = ActivityInDB(**activity_object.dict()) - await activities.insert_one(activity.dict()) + activity = Activity.from_orm(activity_object) + db_session.add(activity) + db_session.commit() + db_session.refresh(activity) # upload video if video_file: # get videofile format - await upload_video(video_file, activity_id, org_id, coursechapter["course_id"]) + await upload_video( + video_file, + activity.activity_uuid, + organization.org_uuid, + course.course_uuid, + ) - # todo : choose whether to update the chapter or not # update chapter - await courses.update_one( - {"chapters_content.coursechapter_id": coursechapter_id}, - {"$addToSet": {"chapters_content.$.activities": activity_id}}, + chapter_activity_object = ChapterActivity( + chapter_id=chapter.id, # type: ignore + activity_id=activity.id, # type: ignore + course_id=coursechapter.course_id, + org_id=coursechapter.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=1, ) - return activity + # Insert ChapterActivity link in DB + db_session.add(chapter_activity_object) + db_session.commit() + db_session.refresh(chapter_activity_object) + + return ActivityRead.from_orm(activity) class ExternalVideo(BaseModel): name: str uri: str type: Literal["youtube", "vimeo"] - coursechapter_id: str + chapter_id: str class ExternalVideoInDB(BaseModel): @@ -121,67 +152,93 @@ class ExternalVideoInDB(BaseModel): async def create_external_video_activity( request: Request, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, data: ExternalVideo, + db_session: Session, ): - activities = request.app.db["activities"] - courses = request.app.db["courses"] - users = request.app.db["users"] + # RBAC check + await rbac_check(request, "activity_x", current_user, "create", db_session) - # get user - user = await users.find_one({"user_id": current_user.user_id}) + # get chapter_id + statement = select(Chapter).where(Chapter.id == data.chapter_id) + chapter = db_session.exec(statement).first() - # generate activity_id - activity_id = str(f"activity_{uuid4()}") + if not chapter: + raise HTTPException( + status_code=404, + detail="Chapter not found", + ) - # get org_id from course - coursechapter = await courses.find_one( - {"chapters_content.coursechapter_id": data.coursechapter_id} - ) + statement = select(CourseChapter).where(CourseChapter.chapter_id == data.chapter_id) + coursechapter = db_session.exec(statement).first() if not coursechapter: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="CourseChapter : No coursechapter found", + status_code=404, + detail="CourseChapter not found", ) - org_id = coursechapter["org_id"] + # generate activity_uuid + activity_uuid = str(f"activity_{uuid4()}") - activity_object = ActivityInDB( - org_id=org_id, - activity_id=activity_id, - coursechapter_id=data.coursechapter_id, + activity_object = Activity( name=data.name, - type="video", + activity_type=ActivityTypeEnum.TYPE_VIDEO, + activity_sub_type=ActivitySubTypeEnum.SUBTYPE_VIDEO_YOUTUBE, + activity_uuid=activity_uuid, + course_id=coursechapter.course_id, + org_id=coursechapter.org_id, + published_version=1, content={ - "external_video": { - "uri": data.uri, - "activity_id": activity_id, - "type": data.type, - } + "uri": data.uri, + "type": data.type, + "activity_uuid": activity_uuid, }, - course_id=coursechapter["course_id"], - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - ) - - await authorization_verify_based_on_roles( - request, - current_user.user_id, - "create", - user["roles"], - activity_id, + version=1, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) # create activity - activity = ActivityInDB(**activity_object.dict()) - await activities.insert_one(activity.dict()) + activity = Activity.from_orm(activity_object) + db_session.add(activity) + db_session.commit() + db_session.refresh(activity) - # todo : choose whether to update the chapter or not # update chapter - await courses.update_one( - {"chapters_content.coursechapter_id": data.coursechapter_id}, - {"$addToSet": {"chapters_content.$.activities": activity_id}}, + chapter_activity_object = ChapterActivity( + chapter_id=coursechapter.chapter_id, # type: ignore + activity_id=activity.id, # type: ignore + course_id=coursechapter.course_id, + org_id=coursechapter.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=1, ) - return activity + # Insert ChapterActivity link in DB + db_session.add(chapter_activity_object) + db_session.commit() + + return ActivityRead.from_orm(activity) + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 97184ecd..1e166d74 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -1,367 +1,551 @@ from datetime import datetime from typing import List, Literal from uuid import uuid4 -from pydantic import BaseModel -from src.security.auth import non_public_endpoint +from sqlmodel import Session, select +from src.db.users import AnonymousUser from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, authorization_verify_based_on_roles_and_authorship, - authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) +from src.db.course_chapters import CourseChapter +from src.db.activities import Activity, ActivityRead +from src.db.chapter_activities import ChapterActivity +from src.db.chapters import ( + Chapter, + ChapterCreate, + ChapterRead, + ChapterUpdate, + ChapterUpdateOrder, +) from src.services.courses.courses import Course -from src.services.courses.activities.activities import ActivityInDB from src.services.users.users import PublicUser from fastapi import HTTPException, status, Request -class CourseChapter(BaseModel): - name: str - description: str - activities: list - - -class CourseChapterInDB(CourseChapter): - coursechapter_id: str - course_id: str - creationDate: str - updateDate: str - - -# Frontend -class CourseChapterMetaData(BaseModel): - chapterOrder: List[str] - chapters: dict - activities: object - - -#### Classes #################################################### - #################################################### # CRUD #################################################### -async def create_coursechapter( +async def create_chapter( request: Request, - coursechapter_object: CourseChapter, - course_id: str, - current_user: PublicUser, -): - courses = request.app.db["courses"] - users = request.app.db["users"] - # get course org_id and verify rights - await courses.find_one({"course_id": course_id}) - user = await users.find_one({"user_id": current_user.user_id}) + chapter_object: ChapterCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> ChapterRead: + chapter = Chapter.from_orm(chapter_object) - # generate coursechapter_id with uuid4 - coursechapter_id = str(f"coursechapter_{uuid4()}") + # Get COurse + statement = select(Course).where(Course.id == chapter_object.course_id) - hasRoleRights = await authorization_verify_based_on_roles( - request, current_user.user_id, "create", user["roles"], course_id + course = db_session.exec(statement).one() + + # RBAC check + await rbac_check(request, "chapter_x", current_user, "create", db_session) + + # complete chapter object + chapter.course_id = chapter_object.course_id + chapter.chapter_uuid = f"chapter_{uuid4()}" + chapter.creation_date = str(datetime.now()) + chapter.update_date = str(datetime.now()) + chapter.org_id = course.org_id + + # Find the last chapter in the course and add it to the list + statement = ( + select(CourseChapter) + .where(CourseChapter.course_id == chapter.course_id) + .order_by(CourseChapter.order) ) + course_chapters = db_session.exec(statement).all() - if not hasRoleRights: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Roles : Insufficient rights to perform this action", + # get last chapter order + last_order = course_chapters[-1].order if course_chapters else 0 + to_be_used_order = last_order + 1 + + # Add chapter to database + db_session.add(chapter) + db_session.commit() + db_session.refresh(chapter) + + chapter = ChapterRead(**chapter.dict(), activities=[]) + + # Check if COurseChapter link exists + + statement = ( + select(CourseChapter) + .where(CourseChapter.chapter_id == chapter.id) + .where(CourseChapter.course_id == chapter.course_id) + .where(CourseChapter.order == to_be_used_order) + ) + course_chapter = db_session.exec(statement).first() + + if not course_chapter: + # Add CourseChapter link + course_chapter = CourseChapter( + course_id=chapter.course_id, + chapter_id=chapter.id, + org_id=chapter.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=to_be_used_order, ) - coursechapter = CourseChapterInDB( - coursechapter_id=coursechapter_id, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - course_id=course_id, - **coursechapter_object.dict(), - ) + # Insert CourseChapter link in DB + db_session.add(course_chapter) + db_session.commit() - courses.update_one( - {"course_id": course_id}, - { - "$addToSet": { - "chapters": coursechapter_id, - "chapters_content": coursechapter.dict(), - } - }, - ) - - return coursechapter.dict() + return chapter -async def get_coursechapter( - request: Request, coursechapter_id: str, current_user: PublicUser -): - courses = request.app.db["courses"] - - coursechapter = await courses.find_one( - {"chapters_content.coursechapter_id": coursechapter_id} - ) - - if coursechapter: - # verify course rights - await verify_rights(request, coursechapter["course_id"], current_user, "read") - coursechapter = CourseChapter(**coursechapter) - - return coursechapter - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="CourseChapter does not exist" - ) - - -async def update_coursechapter( +async def get_chapter( request: Request, - coursechapter_object: CourseChapter, - coursechapter_id: str, - current_user: PublicUser, -): - courses = request.app.db["courses"] + chapter_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> ChapterRead: + statement = select(Chapter).where(Chapter.id == chapter_id) + chapter = db_session.exec(statement).first() - coursechapter = await courses.find_one( - {"chapters_content.coursechapter_id": coursechapter_id} + if not chapter: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" + ) + + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "read", db_session) + + # Get activities for this chapter + statement = ( + select(Activity) + .join(ChapterActivity, Activity.id == ChapterActivity.activity_id) + .where(ChapterActivity.chapter_id == chapter_id) + .distinct(Activity.id) ) - if coursechapter: - # verify course rights - await verify_rights(request, coursechapter["course_id"], current_user, "update") + activities = db_session.exec(statement).all() - coursechapter = CourseChapterInDB( - coursechapter_id=coursechapter_id, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - course_id=coursechapter["course_id"], - **coursechapter_object.dict(), - ) - - courses.update_one( - {"chapters_content.coursechapter_id": coursechapter_id}, - {"$set": {"chapters_content.$": coursechapter.dict()}}, - ) - - return coursechapter - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Coursechapter does not exist" - ) - - -async def delete_coursechapter( - request: Request, coursechapter_id: str, current_user: PublicUser -): - courses = request.app.db["courses"] - - course = await courses.find_one( - {"chapters_content.coursechapter_id": coursechapter_id} + chapter = ChapterRead( + **chapter.dict(), + activities=[ActivityRead(**activity.dict()) for activity in activities], ) - if course: - # verify course rights - await verify_rights(request, course["course_id"], current_user, "delete") - - # Remove coursechapter from course - await courses.update_one( - {"course_id": course["course_id"]}, - {"$pull": {"chapters": coursechapter_id}}, - ) - - await courses.update_one( - {"chapters_content.coursechapter_id": coursechapter_id}, - {"$pull": {"chapters_content": {"coursechapter_id": coursechapter_id}}}, - ) - - return {"message": "Coursechapter deleted"} - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" - ) + return chapter -#################################################### -# Misc -#################################################### - - -async def get_coursechapters( - request: Request, course_id: str, page: int = 1, limit: int = 10 -): - courses = request.app.db["courses"] - - course = await courses.find_one({"course_id": course_id}) - - if course: - course = Course(**course) - coursechapters = course.chapters_content - - return coursechapters - - -async def get_coursechapters_meta( - request: Request, course_id: str, current_user: PublicUser -): - courses = request.app.db["courses"] - activities = request.app.db["activities"] - - await non_public_endpoint(current_user) - - await verify_rights(request, course_id, current_user, "read") - - coursechapters = await courses.find_one( - {"course_id": course_id}, {"chapters": 1, "chapters_content": 1, "_id": 0} - ) - - coursechapters = coursechapters - - if not coursechapters: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" - ) - - # activities - coursechapter_activityIds_global = [] - - # chapters - chapters = {} - if coursechapters["chapters_content"]: - for coursechapter in coursechapters["chapters_content"]: - coursechapter = CourseChapterInDB(**coursechapter) - coursechapter_activityIds = [] - - for activity in coursechapter.activities: - coursechapter_activityIds.append(activity) - coursechapter_activityIds_global.append(activity) - - chapters[coursechapter.coursechapter_id] = { - "id": coursechapter.coursechapter_id, - "name": coursechapter.name, - "activityIds": coursechapter_activityIds, - } - - # activities - activities_list = {} - for activity in await activities.find( - {"activity_id": {"$in": coursechapter_activityIds_global}} - ).to_list(length=100): - activity = ActivityInDB(**activity) - activities_list[activity.activity_id] = { - "id": activity.activity_id, - "name": activity.name, - "type": activity.type, - "content": activity.content, - } - - final = { - "chapters": chapters, - "chapterOrder": coursechapters["chapters"], - "activities": activities_list, - } - - return final - - -async def update_coursechapters_meta( +async def update_chapter( request: Request, - course_id: str, - coursechapters_metadata: CourseChapterMetaData, - current_user: PublicUser, -): - courses = request.app.db["courses"] + chapter_object: ChapterUpdate, + chapter_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> ChapterRead: + statement = select(Chapter).where(Chapter.id == chapter_id) + chapter = db_session.exec(statement).first() - await verify_rights(request, course_id, current_user, "update") + if not chapter: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" + ) - # update chapters in course - await courses.update_one( - {"course_id": course_id}, - {"$set": {"chapters": coursechapters_metadata.chapterOrder}}, - ) + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "update", db_session) - if coursechapters_metadata.chapters is not None: - for ( - coursechapter_id, - chapter_metadata, - ) in coursechapters_metadata.chapters.items(): - filter_query = {"chapters_content.coursechapter_id": coursechapter_id} - update_query = { - "$set": { - "chapters_content.$.activities": chapter_metadata["activityIds"] - } - } - result = await courses.update_one(filter_query, update_query) - if result.matched_count == 0: - # handle error when no documents are matched by the filter query - print(f"No documents found for course chapter ID {coursechapter_id}") + # Update only the fields that were passed in + for var, value in vars(chapter_object).items(): + if value is not None: + setattr(chapter, var, value) - # update activities in coursechapters - activity = request.app.db["activities"] - if coursechapters_metadata.chapters is not None: - for ( - coursechapter_id, - chapter_metadata, - ) in coursechapters_metadata.chapters.items(): - # Update coursechapter_id in activities - filter_query = {"activity_id": {"$in": chapter_metadata["activityIds"]}} - update_query = {"$set": {"coursechapter_id": coursechapter_id}} + chapter.update_date = str(datetime.now()) - result = await activity.update_many(filter_query, update_query) - if result.matched_count == 0: - # handle error when no documents are matched by the filter query - print(f"No documents found for course chapter ID {coursechapter_id}") + db_session.commit() + db_session.refresh(chapter) - return {"detail": "coursechapters metadata updated"} + if chapter: + chapter = await get_chapter( + request, chapter.id, current_user, db_session # type: ignore + ) + + return chapter -#### Security #################################################### - - -async def verify_rights( +async def delete_chapter( request: Request, - course_id: str, - current_user: PublicUser, - action: Literal["read", "update", "delete"], + chapter_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): - courses = request.app.db["courses"] - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) - course = await courses.find_one({"course_id": course_id}) + statement = select(Chapter).where(Chapter.id == chapter_id) + chapter = db_session.exec(statement).first() + + if not chapter: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" + ) + + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "delete", db_session) + + db_session.delete(chapter) + db_session.commit() + + # Remove all linked activities + statement = select(ChapterActivity).where(ChapterActivity.id == chapter.id) + chapter_activities = db_session.exec(statement).all() + + for chapter_activity in chapter_activities: + db_session.delete(chapter_activity) + db_session.commit() + + return {"detail": "chapter deleted"} + + +async def get_course_chapters( + request: Request, + course_id: int, + db_session: Session, + current_user: PublicUser | AnonymousUser, + page: int = 1, + limit: int = 10, +) -> List[ChapterRead]: + statement = ( + select(Chapter) + .join(CourseChapter, Chapter.id == CourseChapter.chapter_id) + .where(CourseChapter.course_id == course_id) + .where(Chapter.course_id == course_id) + .order_by(CourseChapter.order) + .group_by(Chapter.id, CourseChapter.order) + ) + chapters = db_session.exec(statement).all() + + chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters] + + # RBAC check + await rbac_check(request, "chapter_x", current_user, "read", db_session) + + # Get activities for each chapter + for chapter in chapters: + statement = ( + select(ChapterActivity) + .where(ChapterActivity.chapter_id == chapter.id) + .order_by(ChapterActivity.order) + .distinct(ChapterActivity.id, ChapterActivity.order) + ) + chapter_activities = db_session.exec(statement).all() + + for chapter_activity in chapter_activities: + statement = ( + select(Activity) + .where(Activity.id == chapter_activity.activity_id) + .distinct(Activity.id) + ) + activity = db_session.exec(statement).first() + + if activity: + chapter.activities.append(ActivityRead(**activity.dict())) + + return chapters + + +# Important Note : this is legacy code that has been used because +# the frontend is still not adapted for the new data structure, this implementation is absolutely not the best one +# and should not be used for future features +async def DEPRECEATED_get_course_chapters( + request: Request, + course_uuid: str, + current_user: PublicUser, + db_session: Session, +): + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() if not course: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" ) - if action == "read": - if current_user.user_id == "anonymous": - await authorization_verify_if_element_is_public( - request, course_id, current_user.user_id, action - ) - else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) - await authorization_verify_if_user_is_anon(current_user.user_id) + chapters_in_db = await get_course_chapters(request, course.id, db_session, current_user) # type: ignore - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.user_id, - action, - user["roles"], - course_id, - ) - else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) + # activities - await authorization_verify_if_user_is_anon(current_user.user_id) + # chapters + chapters = {} - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.user_id, - action, - user["roles"], - course_id, + for chapter in chapters_in_db: + chapter_activityIds = [] + + for activity in chapter.activities: + print("test", activity) + chapter_activityIds.append(activity.activity_uuid) + + chapters[chapter.chapter_uuid] = { + "uuid": chapter.chapter_uuid, + "id": chapter.id, + "name": chapter.name, + "activityIds": chapter_activityIds, + } + + # activities + activities_list = {} + statement = ( + select(Activity) + .join(ChapterActivity, ChapterActivity.activity_id == Activity.id) + .where(ChapterActivity.activity_id == Activity.id) + .group_by(Activity.id) + ) + activities_in_db = db_session.exec(statement).all() + + for activity in activities_in_db: + activities_list[activity.activity_uuid] = { + "uuid": activity.activity_uuid, + "id": activity.id, + "name": activity.name, + "type": activity.activity_type, + "content": activity.content, + } + + # get chapter order + statement = ( + select(Chapter) + .join(CourseChapter, CourseChapter.chapter_id == Chapter.id) + .where(CourseChapter.chapter_id == Chapter.id) + .group_by(Chapter.id, CourseChapter.order) + .order_by(CourseChapter.order) + ) + chapters_in_db = db_session.exec(statement).all() + + chapterOrder = [] + + for chapter in chapters_in_db: + chapterOrder.append(chapter.chapter_uuid) + + final = { + "chapters": chapters, + "chapterOrder": chapterOrder, + "activities": activities_list, + } + + return final + + +async def reorder_chapters_and_activities( + request: Request, + course_uuid: str, + chapters_order: ChapterUpdateOrder, + current_user: PublicUser, + db_session: Session, +): + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) -#### Security #################################################### + ########### + # Chapters + ########### + + # Delete CourseChapters that are not linked to chapter_id and activity_id and org_id and course_id + statement = ( + select(CourseChapter) + .where( + CourseChapter.course_id == course.id, CourseChapter.org_id == course.org_id + ) + .order_by(CourseChapter.order) + ) + course_chapters = db_session.exec(statement).all() + + chapter_ids_to_keep = [ + chapter_order.chapter_id + for chapter_order in chapters_order.chapter_order_by_ids + ] + for course_chapter in course_chapters: + if course_chapter.chapter_id not in chapter_ids_to_keep: + db_session.delete(course_chapter) + db_session.commit() + + # Delete Chapters that are not in the list of chapters_order + statement = select(Chapter).where(Chapter.course_id == course.id) + chapters = db_session.exec(statement).all() + + chapter_ids_to_keep = [ + chapter_order.chapter_id + for chapter_order in chapters_order.chapter_order_by_ids + ] + + for chapter in chapters: + if chapter.id not in chapter_ids_to_keep: + db_session.delete(chapter) + db_session.commit() + + # If links do not exists, create them + for chapter_order in chapters_order.chapter_order_by_ids: + statement = ( + select(CourseChapter) + .where( + CourseChapter.chapter_id == chapter_order.chapter_id, + CourseChapter.course_id == course.id, + ) + .order_by(CourseChapter.order) + ) + course_chapter = db_session.exec(statement).first() + + if not course_chapter: + # Add CourseChapter link + course_chapter = CourseChapter( + chapter_id=chapter_order.chapter_id, + course_id=course.id, # type: ignore + org_id=course.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=chapter_order.chapter_id, + ) + + # Insert CourseChapter link in DB + db_session.add(course_chapter) + db_session.commit() + + # Update order of chapters + for chapter_order in chapters_order.chapter_order_by_ids: + statement = ( + select(CourseChapter) + .where( + CourseChapter.chapter_id == chapter_order.chapter_id, + CourseChapter.course_id == course.id, + ) + .order_by(CourseChapter.order) + ) + course_chapter = db_session.exec(statement).first() + + if course_chapter: + # Get the order from the index of the chapter_order_by_ids list + course_chapter.order = chapters_order.chapter_order_by_ids.index( + chapter_order + ) + db_session.commit() + + ########### + # Activities + ########### + + # Delete ChapterActivities that are no longer part of the new order + statement = ( + select(ChapterActivity) + .where( + ChapterActivity.course_id == course.id, + ChapterActivity.org_id == course.org_id, + ) + .order_by(ChapterActivity.order) + ) + chapter_activities = db_session.exec(statement).all() + + activity_ids_to_delete = [] + for chapter_activity in chapter_activities: + if ( + chapter_activity.chapter_id not in chapter_ids_to_keep + or chapter_activity.activity_id not in activity_ids_to_delete + ): + activity_ids_to_delete.append(chapter_activity.activity_id) + + for activity_id in activity_ids_to_delete: + statement = ( + select(ChapterActivity) + .where( + ChapterActivity.activity_id == activity_id, + ChapterActivity.course_id == course.id, + ) + .order_by(ChapterActivity.order) + ) + chapter_activity = db_session.exec(statement).first() + + db_session.delete(chapter_activity) + db_session.commit() + + + # If links do not exist, create them + chapter_activity_map = {} + for chapter_order in chapters_order.chapter_order_by_ids: + for activity_order in chapter_order.activities_order_by_ids: + if activity_order.activity_id in chapter_activity_map and chapter_activity_map[activity_order.activity_id] != chapter_order.chapter_id: + continue + + statement = ( + select(ChapterActivity) + .where( + ChapterActivity.chapter_id == chapter_order.chapter_id, + ChapterActivity.activity_id == activity_order.activity_id, + ) + .order_by(ChapterActivity.order) + ) + chapter_activity = db_session.exec(statement).first() + + if not chapter_activity: + # Add ChapterActivity link + chapter_activity = ChapterActivity( + chapter_id=chapter_order.chapter_id, + activity_id=activity_order.activity_id, + org_id=course.org_id, + course_id=course.id, # type: ignore + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + order=activity_order.activity_id, + ) + + # Insert ChapterActivity link in DB + db_session.add(chapter_activity) + db_session.commit() + + chapter_activity_map[activity_order.activity_id] = chapter_order.chapter_id + + # Update order of activities + for chapter_order in chapters_order.chapter_order_by_ids: + for activity_order in chapter_order.activities_order_by_ids: + statement = ( + select(ChapterActivity) + .where( + ChapterActivity.chapter_id == chapter_order.chapter_id, + ChapterActivity.activity_id == activity_order.activity_id, + ) + .order_by(ChapterActivity.order) + ) + chapter_activity = db_session.exec(statement).first() + + if chapter_activity: + # Get the order from the index of the chapter_order_by_ids list + chapter_activity.order = chapter_order.activities_order_by_ids.index( + activity_order + ) + db_session.commit() + + return {"detail": "Chapters reordered"} + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index 05e133bf..3a8c8810 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -1,27 +1,23 @@ +from datetime import datetime from typing import List, Literal from uuid import uuid4 -from pydantic import BaseModel -from src.security.rbac.rbac import authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon +from sqlmodel import Session, select +from src.db.users import AnonymousUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) +from src.db.collections import ( + Collection, + CollectionCreate, + CollectionRead, + CollectionUpdate, +) +from src.db.collections_courses import CollectionCourse +from src.db.courses import Course from src.services.users.users import PublicUser from fastapi import HTTPException, status, Request -#### Classes #################################################### - - -class Collection(BaseModel): - name: str - description: str - courses: List[str] # course_id - public: bool - org_id: str # org_id - - -class CollectionInDB(Collection): - collection_id: str - authors: List[str] # user_id - - -#### Classes #################################################### #################################################### # CRUD @@ -29,134 +25,181 @@ class CollectionInDB(Collection): async def get_collection( - request: Request, collection_id: str, current_user: PublicUser -): - collections = request.app.db["collections"] - - collection = await collections.find_one({"collection_id": collection_id}) - - # verify collection rights - await verify_collection_rights( - request, collection_id, current_user, "read", collection["org_id"] - ) + request: Request, collection_uuid: str, current_user: PublicUser, db_session: Session +) -> CollectionRead: + statement = select(Collection).where(Collection.collection_uuid == collection_uuid) + collection = db_session.exec(statement).first() if not collection: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" ) - collection = Collection(**collection) + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "read", db_session + ) - # add courses to collection - courses = request.app.db["courses"] - courseids = [course for course in collection.courses] + # get courses in collection + statement = ( + select(Course) + .join(CollectionCourse, Course.id == CollectionCourse.course_id) + .distinct(Course.id) + ) + courses = db_session.exec(statement).all() - collection.courses = [] - collection.courses = courses.find({"course_id": {"$in": courseids}}, {"_id": 0}) - - collection.courses = [ - course for course in await collection.courses.to_list(length=100) - ] + collection = CollectionRead(**collection.dict(), courses=courses) return collection async def create_collection( - request: Request, collection_object: Collection, current_user: PublicUser -): - collections = request.app.db["collections"] + request: Request, + collection_object: CollectionCreate, + current_user: PublicUser, + db_session: Session, +) -> CollectionRead: + collection = Collection.from_orm(collection_object) - # find if collection already exists using name - isCollectionNameAvailable = await collections.find_one( - {"name": collection_object.name} + # RBAC check + await rbac_check(request, "collection_x", current_user, "create", db_session) + + # Complete the collection object + collection.collection_uuid = f"collection_{uuid4()}" + collection.creation_date = str(datetime.now()) + collection.update_date = str(datetime.now()) + + # Add collection to database + db_session.add(collection) + db_session.commit() + + db_session.refresh(collection) + + # Link courses to collection + if collection: + for course_id in collection_object.courses: + collection_course = CollectionCourse( + collection_id=int(collection.id), # type: ignore + course_id=course_id, + org_id=int(collection_object.org_id), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + # Add collection_course to database + db_session.add(collection_course) + + db_session.commit() + db_session.refresh(collection) + + # Get courses once again + statement = ( + select(Course) + .join(CollectionCourse, Course.id == CollectionCourse.course_id) + .distinct(Course.id) ) + courses = db_session.exec(statement).all() - # TODO - # await verify_collection_rights("*", current_user, "create") + collection = CollectionRead(**collection.dict(), courses=courses) - if isCollectionNameAvailable: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Collection name already exists", - ) - - # generate collection_id with uuid4 - collection_id = str(f"collection_{uuid4()}") - - collection = CollectionInDB( - collection_id=collection_id, - authors=[current_user.user_id], - **collection_object.dict(), - ) - - collection_in_db = await collections.insert_one(collection.dict()) - - if not collection_in_db: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) - - return collection.dict() + return CollectionRead.from_orm(collection) async def update_collection( request: Request, - collection_object: Collection, - collection_id: str, + collection_object: CollectionUpdate, + collection_uuid: str, current_user: PublicUser, -): - # verify collection rights - - collections = request.app.db["collections"] - - collection = await collections.find_one({"collection_id": collection_id}) - - await verify_collection_rights( - request, collection_id, current_user, "update", collection["org_id"] - ) + db_session: Session, +) -> CollectionRead: + statement = select(Collection).where(Collection.collection_uuid == collection_uuid) + collection = db_session.exec(statement).first() if not collection: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" ) - updated_collection = CollectionInDB( - collection_id=collection_id, **collection_object.dict() + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "update", db_session ) - await collections.update_one( - {"collection_id": collection_id}, {"$set": updated_collection.dict()} + courses = collection_object.courses + + del collection_object.courses + + # Update only the fields that were passed in + for var, value in vars(collection_object).items(): + if value is not None: + setattr(collection, var, value) + + collection.update_date = str(datetime.now()) + + # Update only the fields that were passed in + for var, value in vars(collection_object).items(): + if value is not None: + setattr(collection, var, value) + + statement = select(CollectionCourse).where( + CollectionCourse.collection_id == collection.id + ) + collection_courses = db_session.exec(statement).all() + + # Delete all collection_courses + for collection_course in collection_courses: + db_session.delete(collection_course) + + # Add new collection_courses + for course in courses or []: + collection_course = CollectionCourse( + collection_id=int(collection.id), # type: ignore + course_id=int(course), + org_id=int(collection.org_id), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + # Add collection_course to database + db_session.add(collection_course) + + db_session.commit() + db_session.refresh(collection) + + # Get courses once again + statement = ( + select(Course) + .join(CollectionCourse, Course.id == CollectionCourse.course_id) + .distinct(Course.id) ) - return Collection(**updated_collection.dict()) + courses = db_session.exec(statement).all() + + collection = CollectionRead(**collection.dict(), courses=courses) + + return collection async def delete_collection( - request: Request, collection_id: str, current_user: PublicUser + request: Request, collection_uuid: str, current_user: PublicUser, db_session: Session ): - collections = request.app.db["collections"] - - collection = await collections.find_one({"collection_id": collection_id}) - - await verify_collection_rights( - request, collection_id, current_user, "delete", collection["org_id"] - ) + statement = select(Collection).where(Collection.collection_uuid == collection_uuid) + collection = db_session.exec(statement).first() if not collection: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" + status_code=404, + detail="Collection not found", ) - isDeleted = await collections.delete_one({"collection_id": collection_id}) + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "delete", db_session + ) - if isDeleted: - return {"detail": "collection deleted"} - else: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) + # delete collection from database + db_session.delete(collection) + db_session.commit() + + return {"detail": "Collection deleted"} #################################################### @@ -167,76 +210,55 @@ async def delete_collection( async def get_collections( request: Request, org_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, + db_session: Session, page: int = 1, limit: int = 10, -): - collections = request.app.db["collections"] +) -> List[CollectionRead]: + # RBAC check + await rbac_check(request, "collection_x", current_user, "read", db_session) + + statement = ( + select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) + ) + collections = db_session.exec(statement).all() - if current_user.user_id == "anonymous": - all_collections = collections.find( - {"org_id": org_id, "public": True}, {"_id": 0} - ) - else: - # get all collections from database without ObjectId - all_collections = ( - collections.find({"org_id": org_id}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(limit) + + collections_with_courses = [] + for collection in collections: + statement = ( + select(Course) + .join(CollectionCourse, Course.id == CollectionCourse.course_id) + .distinct(Course.id) ) + courses = db_session.exec(statement).all() - # create list of collections and include courses in each collection - collections_list = [] - for collection in await all_collections.to_list(length=100): - collection = CollectionInDB(**collection) - collections_list.append(collection) + collection = CollectionRead(**collection.dict(), courses=courses) + collections_with_courses.append(collection) - collection_courses = [course for course in collection.courses] - # add courses to collection - courses = request.app.db["courses"] - collection.courses = [] - collection.courses = courses.find( - {"course_id": {"$in": collection_courses}}, {"_id": 0} - ) - - collection.courses = [ - course for course in await collection.courses.to_list(length=100) - ] - - return collections_list + return collections_with_courses -#### Security #################################################### +## 🔒 RBAC Utils ## -async def verify_collection_rights( +async def rbac_check( request: Request, - collection_id: str, - current_user: PublicUser, + course_id: str, + current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], - org_id: str, + db_session: Session, ): - collections = request.app.db["collections"] - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) - collection = await collections.find_one({"collection_id": collection_id}) - - if not collection and action != "create" and collection_id != "*": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" - ) - - # Collections are public by default for now - if current_user.user_id == "anonymous" and action == "read": - return True - - await authorization_verify_if_user_is_anon(current_user.user_id) + await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_based_on_roles_and_authorship( - request, current_user.user_id, action, user["roles"], collection_id + request, + current_user.id, + action, + course_id, + db_session, ) -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 86841d26..d7366503 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,413 +1,391 @@ -import json -from typing import List, Literal, Optional +from typing import Literal from uuid import uuid4 -from pydantic import BaseModel +from sqlmodel import Session, select +from src.db.organizations import Organization +from src.db.trails import TrailRead + +from src.services.trail.trail import get_user_trail_with_orgid +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.users import PublicUser, AnonymousUser, User, UserRead +from src.db.courses import ( + Course, + CourseCreate, + CourseRead, + CourseUpdate, + FullCourseReadWithTrail, +) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) -from src.services.courses.activities.activities import ActivityInDB from src.services.courses.thumbnails import upload_thumbnail -from src.services.users.schemas.users import AnonymousUser -from src.services.users.users import PublicUser -from fastapi import HTTPException, Request, status, UploadFile +from fastapi import HTTPException, Request, UploadFile from datetime import datetime -#### Classes #################################################### - -class Course(BaseModel): - name: str - mini_description: str - description: str - learnings: List[str] - thumbnail: str - public: bool - chapters: List[str] - chapters_content: Optional[List] - org_id: str - - -class CourseInDB(Course): - course_id: str - creationDate: str - updateDate: str - authors: List[str] - - -# TODO : wow terrible, fix this -# those models need to be available only in the chapters service -class CourseChapter(BaseModel): - name: str - description: str - activities: list - - -class CourseChapterInDB(CourseChapter): - coursechapter_id: str - course_id: str - creationDate: str - updateDate: str - - -#### Classes #################################################### - -# TODO : Add courses photo & cover upload and delete - - -#################################################### -# CRUD -#################################################### - - -async def get_course(request: Request, course_id: str, current_user: PublicUser): - courses = request.app.db["courses"] - - course = await courses.find_one({"course_id": course_id}) - - # verify course rights - await verify_rights(request, course_id, current_user, "read") +async def get_course( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() if not course: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + status_code=404, + detail="Course not found", ) - course = Course(**course) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course = CourseRead(**course.dict(), authors=authors) + return course -async def get_course_meta(request: Request, course_id: str, current_user: PublicUser): - courses = request.app.db["courses"] - trails = request.app.db["trails"] +async def get_course_meta( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> FullCourseReadWithTrail: + # Avoid circular import + from src.services.courses.chapters import get_course_chapters - course = await courses.find_one({"course_id": course_id}) - activities = request.app.db["activities"] - - # verify course rights - await verify_rights(request, course_id, current_user, "read") + course_statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(course_statement).first() if not course: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + status_code=404, + detail="Course not found", ) - coursechapters = await courses.find_one( - {"course_id": course_id}, {"chapters_content": 1, "_id": 0} + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course = CourseRead(**course.dict(), authors=authors) + + # Get course chapters + chapters = await get_course_chapters(request, course.id, db_session, current_user) + + # Trail + trail = await get_user_trail_with_orgid( + request, current_user, course.org_id, db_session ) - # activities - coursechapter_activityIds_global = [] + trail = TrailRead.from_orm(trail) - # chapters - chapters = {} - if coursechapters["chapters_content"]: - for coursechapter in coursechapters["chapters_content"]: - coursechapter = CourseChapterInDB(**coursechapter) - coursechapter_activityIds = [] - - for activity in coursechapter.activities: - coursechapter_activityIds.append(activity) - coursechapter_activityIds_global.append(activity) - - chapters[coursechapter.coursechapter_id] = { - "id": coursechapter.coursechapter_id, - "name": coursechapter.name, - "activityIds": coursechapter_activityIds, - } - - # activities - activities_list = {} - for activity in await activities.find( - {"activity_id": {"$in": coursechapter_activityIds_global}} - ).to_list(length=100): - activity = ActivityInDB(**activity) - activities_list[activity.activity_id] = { - "id": activity.activity_id, - "name": activity.name, - "type": activity.type, - "content": activity.content, - } - - chapters_list_with_activities = [] - for chapter in chapters: - chapters_list_with_activities.append( - { - "id": chapters[chapter]["id"], - "name": chapters[chapter]["name"], - "activities": [ - activities_list[activity] - for activity in chapters[chapter]["activityIds"] - ], - } - ) - course = CourseInDB(**course) - - # Get activity by user - trail = await trails.find_one( - {"courses.course_id": course_id, "user_id": current_user.user_id} + return FullCourseReadWithTrail( + **course.dict(), + chapters=chapters, + trail=trail if trail else None, ) - if trail: - # get only the course where course_id == course_id - trail_course = next( - (course for course in trail["courses"] if course["course_id"] == course_id), - None, - ) - else: - trail_course = "" - - return { - "course": course, - "chapters": chapters_list_with_activities, - "trail": trail_course, - } async def create_course( request: Request, - course_object: Course, - org_id: str, - current_user: PublicUser, + org_id: int, + course_object: CourseCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, thumbnail_file: UploadFile | None = None, ): - courses = request.app.db["courses"] - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) + course = Course.from_orm(course_object) - # generate course_id with uuid4 - course_id = str(f"course_{uuid4()}") + # RBAC check + await rbac_check(request, "course_x", current_user, "create", db_session) - # TODO(fix) : the implementation here is clearly not the best one (this entire function) - course_object.org_id = org_id - course_object.chapters_content = [] + # Complete course object + course.org_id = course.org_id - await authorization_verify_based_on_roles( - request, - current_user.user_id, - "create", - user["roles"], - course_id, - ) + # Get org uuid + org_statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(org_statement).first() + course.course_uuid = str(f"course_{uuid4()}") + course.creation_date = str(datetime.now()) + course.update_date = str(datetime.now()) + # Upload thumbnail if thumbnail_file and thumbnail_file.filename: - name_in_disk = ( - f"{course_id}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" - ) + name_in_disk = f"{course.course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" await upload_thumbnail( - thumbnail_file, name_in_disk, course_object.org_id, course_id + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid ) - course_object.thumbnail = name_in_disk + course.thumbnail_image = name_in_disk - course = CourseInDB( - course_id=course_id, - authors=[current_user.user_id], - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - **course_object.dict(), + # Insert course + db_session.add(course) + db_session.commit() + db_session.refresh(course) + + # Make the user the creator of the course + resource_author = ResourceAuthor( + resource_uuid=course.course_uuid, + user_id=current_user.id, + authorship=ResourceAuthorshipEnum.CREATOR, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) - course_in_db = await courses.insert_one(course.dict()) + # Insert course author + db_session.add(resource_author) + db_session.commit() + db_session.refresh(resource_author) - if not course_in_db: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() - return course.dict() + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course = CourseRead(**course.dict(), authors=authors) + + return CourseRead.from_orm(course) async def update_course_thumbnail( request: Request, - course_id: str, - current_user: PublicUser, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, thumbnail_file: UploadFile | None = None, ): - courses = request.app.db["courses"] + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() - course = await courses.find_one({"course_id": course_id}) - - # verify course rights - await verify_rights(request, course_id, current_user, "update") - - # TODO(fix) : the implementation here is clearly not the best one - if course: - creationDate = course["creationDate"] - authors = course["authors"] - if thumbnail_file and thumbnail_file.filename: - name_in_disk = f"{course_id}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" - course = Course(**course).copy(update={"thumbnail": name_in_disk}) - await upload_thumbnail( - thumbnail_file, name_in_disk, course.org_id, course_id - ) - - updated_course = CourseInDB( - course_id=course_id, - creationDate=creationDate, - authors=authors, - updateDate=str(datetime.now()), - **course.dict(), - ) - - await courses.update_one( - {"course_id": course_id}, {"$set": updated_course.dict()} - ) - - return CourseInDB(**updated_course.dict()) - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" - ) - - -async def update_course( - request: Request, course_object: Course, course_id: str, current_user: PublicUser -): - courses = request.app.db["courses"] - - course = await courses.find_one({"course_id": course_id}) - - # verify course rights - await verify_rights(request, course_id, current_user, "update") - - if course: - creationDate = course["creationDate"] - authors = course["authors"] - - # get today's date - datetime_object = datetime.now() - - updated_course = CourseInDB( - course_id=course_id, - creationDate=creationDate, - authors=authors, - updateDate=str(datetime_object), - **course_object.dict(), - ) - - await courses.update_one( - {"course_id": course_id}, {"$set": updated_course.dict()} - ) - - return CourseInDB(**updated_course.dict()) - - else: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" - ) - - -async def delete_course(request: Request, course_id: str, current_user: PublicUser): - courses = request.app.db["courses"] - - course = await courses.find_one({"course_id": course_id}) - - # verify course rights - await verify_rights(request, course_id, current_user, "delete") + name_in_disk = None if not course: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" + status_code=404, + detail="Course not found", ) - isDeleted = await courses.delete_one({"course_id": course_id}) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) - if isDeleted: - return {"detail": "Course deleted"} + # Get org uuid + org_statement = select(Organization).where(Organization.id == course.org_id) + org = db_session.exec(org_statement).first() + + # Upload thumbnail + if thumbnail_file and thumbnail_file.filename: + name_in_disk = f"{course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" + await upload_thumbnail( + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid + ) + + # Update course + if name_in_disk: + course.thumbnail_image = name_in_disk else: raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", + status_code=500, + detail="Issue with thumbnail upload", ) + # Complete the course object + course.update_date = str(datetime.now()) -#################################################### -# Misc -#################################################### + db_session.add(course) + db_session.commit() + db_session.refresh(course) + + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course = CourseRead(**course.dict(), authors=authors) + + return course + + +async def update_course( + request: Request, + course_object: CourseUpdate, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Update only the fields that were passed in + for var, value in vars(course_object).items(): + if value is not None: + setattr(course, var, value) + + # Complete the course object + course.update_date = str(datetime.now()) + + db_session.add(course) + db_session.commit() + db_session.refresh(course) + + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course = CourseRead(**course.dict(), authors=authors) + + return course + + +async def delete_course( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + + db_session.delete(course) + db_session.commit() + + return {"detail": "Course deleted"} async def get_courses_orgslug( request: Request, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, + org_slug: str, + db_session: Session, page: int = 1, limit: int = 10, - org_slug: str | None = None, ): - courses = request.app.db["courses"] - orgs = request.app.db["organizations"] + statement_public = ( + select(Course) + .join(Organization) + .where(Organization.slug == org_slug, Course.public is True) + ) + statement_all = ( + select(Course).join(Organization).where(Organization.slug == org_slug) + ) - # get org_id from slug - org = await orgs.find_one({"slug": org_slug}) - - if not org: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" - ) - - # show only public courses if user is not logged in - if current_user.user_id == "anonymous": - all_courses = ( - courses.find({"org_id": org["org_id"], "public": True}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(limit) - ) + if current_user.id == 0: + statement = statement_public else: - all_courses = ( - courses.find({"org_id": org["org_id"]}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(limit) + # RBAC check + await authorization_verify_if_user_is_anon(current_user.id) + + statement = statement_all + + courses = db_session.exec(statement) + + courses = [CourseRead(**course.dict(), authors=[]) for course in courses] + + # for every course, get the authors + for course in courses: + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) ) + authors = db_session.exec(authors_statement).all() - return [ - json.loads(json.dumps(course, default=str)) - for course in await all_courses.to_list(length=100) - ] + # convert from User to UserRead + authors = [UserRead.from_orm(author) for author in authors] + + course.authors = authors + + return courses -#### Security #################################################### +## 🔒 RBAC Utils ## -async def verify_rights( +async def rbac_check( request: Request, - course_id: str, + course_uuid: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], + db_session: Session, ): if action == "read": - if current_user.user_id == "anonymous": + if current_user.id == 0: # Anonymous user await authorization_verify_if_element_is_public( - request, course_id, current_user.user_id, action + request, course_uuid, action, db_session ) else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.user_id, - action, - user["roles"], - course_id, + request, current_user.id, action, course_uuid, db_session ) else: - users = request.app.db["users"] - user = await users.find_one({"user_id": current_user.user_id}) - - await authorization_verify_if_user_is_anon(current_user.user_id) + await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_based_on_roles_and_authorship( request, - current_user.user_id, + current_user.id, action, - user["roles"], - course_id, + course_uuid, + db_session, ) -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/dev/dev.py b/apps/api/src/services/dev/dev.py index e74b25d7..386f5c7b 100644 --- a/apps/api/src/services/dev/dev.py +++ b/apps/api/src/services/dev/dev.py @@ -16,3 +16,5 @@ def isDevModeEnabledOrRaise(): return True else: raise HTTPException(status_code=403, detail="Development mode is disabled") + + diff --git a/apps/api/src/services/dev/migration_from_mongo.py b/apps/api/src/services/dev/migration_from_mongo.py new file mode 100644 index 00000000..3ceae583 --- /dev/null +++ b/apps/api/src/services/dev/migration_from_mongo.py @@ -0,0 +1,275 @@ +import datetime +from fastapi import Request +from sqlmodel import Session, select +from src.db.blocks import Block, BlockTypeEnum +from src.db.chapter_activities import ChapterActivity +from src.db.activities import Activity, ActivitySubTypeEnum, ActivityTypeEnum +from src.db.course_chapters import CourseChapter +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.user_organizations import UserOrganization +from src.db.chapters import Chapter +from src.db.courses import Course +from src.db.users import User + +from src.db.organizations import Organization + + +async def start_migrate_from_mongo(request: Request, db_session: Session): + orgs = request.app.db["organizations"] + + ## ----> Organizations migration + org_db_list = await orgs.find().to_list(length=100) + + for org in org_db_list: + org_to_add = Organization( + name=org["name"], + description=org["description"], + slug=org["slug"], + logo_image=org["logo"], + email=org["email"], + org_uuid=org["org_id"], + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + db_session.add(org_to_add) + db_session.commit() + + print("Migrated organizations.") + + ## ----> Users migration + users = request.app.db["users"] + + users_db_list = await users.find().to_list(length=100) + + for user in users_db_list: + user_to_add = User( + email=user["email"], + username=user["username"], + first_name="", + last_name="", + user_uuid=user["user_id"], + password=user["password"], + creation_date=user["creation_date"], + update_date=user["update_date"], + ) + db_session.add(user_to_add) + db_session.commit() + + # Link Orgs to users and make them owners + for org in user["orgs"]: + statement = select(Organization).where( + Organization.org_uuid == org["org_id"] + ) + org_from_db = db_session.exec(statement).first() + + statement = select(User).where(User.user_uuid == user["user_id"]) + user_from_db = db_session.exec(statement).first() + + user_org_object = UserOrganization( + user_id=user_from_db.id, # type: ignore + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + role_id=1, + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + db_session.add(user_org_object) + db_session.commit() + + print("Migrated users and linked them to orgs.") + + ## ----> Courses migration + courses = request.app.db["courses"] + + courses_db_list = await courses.find().to_list(length=300) + + for course in courses_db_list: + # Get the organization id + statement = select(Organization).where( + Organization.org_uuid == course["org_id"] + ) + org_from_db = db_session.exec(statement).first() + + course_to_add = Course( + name=course["name"], + description=course["description"], + about=course["description"], + learnings="", + course_uuid=course["course_id"], + thumbnail_image=course["thumbnail"], + tags="", + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + public=course["public"], + creation_date=str(course["creationDate"]), + update_date=str(course["updateDate"]), + ) + db_session.add(course_to_add) + db_session.commit() + + # Get this course + statement = select(Course).where(Course.course_uuid == course["course_id"]) + course_from_db = db_session.exec(statement).first() + + # Add Authorship + authors = course["authors"] + + for author in authors: + # Get the user id + statement = select(User).where(User.user_uuid == author) + user_from_db = db_session.exec(statement).first() + + authorship = ResourceAuthor( + resource_uuid=course_from_db.course_uuid, # type: ignore + user_id=user_from_db.id if user_from_db is not None else None, # type: ignore + authorship=ResourceAuthorshipEnum.CREATOR, + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + db_session.add(authorship) + db_session.commit() + + print("Added authorship.") + + ## ----> Chapters migration & Link + + chapter_object = course["chapters_content"] + order = 0 + for chapter in chapter_object: + chapter_to_add = Chapter( + name=chapter["name"], + description=chapter["description"], + chapter_uuid=chapter["coursechapter_id"].replace( + "coursechapter", "chapter" + ), + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + course_id=course_from_db.id, # type: ignore + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + db_session.add(chapter_to_add) + db_session.commit() + + # Get this chapter + statement = select(Chapter).where( + Chapter.chapter_uuid + == chapter["coursechapter_id"].replace("coursechapter", "chapter") + ) + chapter_from_db = db_session.exec(statement).first() + + # Link chapter to course + coursechapter_to_add = CourseChapter( + chapter_id=chapter_from_db.id, # type: ignore + course_id=course_from_db.id, # type: ignore + order=order, + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + + db_session.add(coursechapter_to_add) + db_session.commit() + + order += 1 + + ## ----> Activities migration + activities = request.app.db["activities"] + activities_db_list = await activities.find( + {"coursechapter_id": chapter["coursechapter_id"]} + ).to_list(length=100) + + activity_order = 0 + + for activity in activities_db_list: + type_to_use = ActivityTypeEnum.TYPE_CUSTOM + sub_type_to_use = ActivityTypeEnum.TYPE_CUSTOM + + if activity["type"] == "video": + type_to_use = ActivityTypeEnum.TYPE_VIDEO + sub_type_to_use = ActivitySubTypeEnum.SUBTYPE_VIDEO_HOSTED + + if "external_video" in activity["content"]: + type_to_use = ActivityTypeEnum.TYPE_VIDEO + sub_type_to_use = ActivitySubTypeEnum.SUBTYPE_VIDEO_YOUTUBE + + if activity["type"] == "documentpdf": + type_to_use = ActivityTypeEnum.TYPE_DOCUMENT + sub_type_to_use = ActivitySubTypeEnum.SUBTYPE_DOCUMENT_PDF + + if activity["type"] == "dynamic": + type_to_use = ActivityTypeEnum.TYPE_DYNAMIC + sub_type_to_use = ActivitySubTypeEnum.SUBTYPE_DYNAMIC_PAGE + + activity_to_add = Activity( + name=activity["name"], + activity_uuid=activity["activity_id"], + version=1, + published_version=1, + activity_type=type_to_use, + content=activity["content"], + activity_sub_type=sub_type_to_use, + chapter_id=chapter_from_db.id, # type: ignore + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + course_id=course_from_db.id, # type: ignore + creation_date=str(activity["creationDate"]), + update_date=str(activity["updateDate"]), + ) + db_session.add(activity_to_add) + db_session.commit() + + # Link activity to chapter + statement = select(Activity).where( + Activity.activity_uuid == activity["activity_id"] + ) + + activity_from_db = db_session.exec(statement).first() + + activitychapter_to_add = ChapterActivity( + chapter_id=chapter_from_db.id, # type: ignore + activity_id=activity_from_db.id, # type: ignore + order=activity_order, + course_id=course_from_db.id, # type: ignore + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + + db_session.add(activitychapter_to_add) + db_session.commit() + + activity_order += 1 + + ## ----> Blocks migration + blocks = request.app.db["blocks"] + + blocks_db_list = await blocks.find( + {"activity_id": activity["activity_id"]} + ).to_list(length=200) + + for block in blocks_db_list: + type_to_use = BlockTypeEnum.BLOCK_CUSTOM + + if block["block_type"] == "imageBlock": + type_to_use = BlockTypeEnum.BLOCK_IMAGE + + if block["block_type"] == "videoBlock": + type_to_use = BlockTypeEnum.BLOCK_VIDEO + + if block["block_type"] == "pdfBlock": + type_to_use = BlockTypeEnum.BLOCK_DOCUMENT_PDF + + print('block', block) + + block_to_add = Block( + block_uuid=block["block_id"], + content=block["block_data"], + block_type=type_to_use, + activity_id=activity_from_db.id, # type: ignore + org_id=org_from_db.id if org_from_db is not None else None, # type: ignore + course_id=course_from_db.id, # type: ignore + chapter_id=chapter_from_db.id, # type: ignore + creation_date=str(datetime.datetime.now()), + update_date=str(datetime.datetime.now()), + ) + db_session.add(block_to_add) + db_session.commit() + + return "Migration successfull." diff --git a/apps/api/src/services/dev/mocks/initial.py b/apps/api/src/services/dev/mocks/initial.py deleted file mode 100644 index 7d8552e9..00000000 --- a/apps/api/src/services/dev/mocks/initial.py +++ /dev/null @@ -1,214 +0,0 @@ -import os -import requests -from datetime import datetime -from uuid import uuid4 -from fastapi import Request -from src.security.security import security_hash_password -from src.services.courses.chapters import CourseChapter, create_coursechapter -from src.services.courses.activities.activities import Activity, create_activity -from src.services.users.users import PublicUser, UserInDB - -from src.services.orgs.orgs import Organization, create_org -from src.services.roles.schemas.roles import Permission, Elements, RoleInDB -from src.services.courses.courses import CourseInDB -from faker import Faker - - -async def create_initial_data(request: Request): - fake = Faker(['en_US']) - fake_multilang = Faker( - ['en_US', 'de_DE', 'ja_JP', 'es_ES', 'it_IT', 'pt_BR', 'ar_PS']) - - - # Create users - ######################################## - - database_users = request.app.db["users"] - await database_users.delete_many({}) - - users = [] - admin_user = UserInDB( - user_id="user_admin", - creation_date=str(datetime.now()), - update_date=str(datetime.now()), - roles= [], - orgs=[], - username="admin", - email="admin@admin.admin", - password=str(await security_hash_password("admin")), - ) - - await database_users.insert_one(admin_user.dict()) - - # find admin user - users = request.app.db["users"] - admin_user = await users.find_one({"username": "admin"}) - - if admin_user: - admin_user = UserInDB(**admin_user) - current_user = PublicUser(**admin_user.dict()) - else: - raise Exception("Admin user not found") - # Create roles - ######################################## - - database_roles = request.app.db["roles"] - await database_roles.delete_many({}) - - - roles = [] - admin_role = RoleInDB( - name="Admin", - description="Admin", - elements=Elements( - courses=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - users=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - houses=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - collections=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - organizations=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - coursechapters=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - activities=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), - ), - org_id="org_test", - role_id="role_admin", - created_at=str(datetime.now()), - updated_at=str(datetime.now()), - ) - - roles.append(admin_role) - - for role in roles: - database_roles.insert_one(role.dict()) - - - # Create organizations - ######################################## - - database_orgs = request.app.db["organizations"] - await database_orgs.delete_many({}) - - organizations = [] - for i in range(0, 2): - company = fake.company() - # remove whitespace and special characters and make lowercase - slug = ''.join(e for e in company if e.isalnum()).lower() - org = Organization( - name=company, - description=fake.unique.text(), - email=fake.unique.email(), - slug=slug, - logo="", - default=False - ) - organizations.append(org) - await create_org(request, org, current_user) - - - # Generate Courses and CourseChapters - ######################################## - - database_courses = request.app.db["courses"] - await database_courses.delete_many({}) - - courses = [] - orgs = request.app.db["organizations"] - - if await orgs.count_documents({}) > 0: - for org in await orgs.find().to_list(length=100): - for i in range(0, 5): - - # get image in BinaryIO format from unsplash and save it to disk - image = requests.get( - "https://source.unsplash.com/random/800x600") - with open("thumbnail.jpg", "wb") as f: - f.write(image.content) - - course_id = f"course_{uuid4()}" - course = CourseInDB( - name=fake_multilang.unique.sentence(), - description=fake_multilang.unique.text(), - mini_description=fake_multilang.unique.text(), - thumbnail="thumbnail", - org_id=org['org_id'], - learnings=[fake_multilang.unique.sentence() - for i in range(0, 5)], - public=True, - chapters=[], - course_id=course_id, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - authors=[current_user.user_id], - chapters_content=[], - ) - - courses = request.app.db["courses"] - name_in_disk = f"test_mock{course_id}.jpeg" - - image = requests.get( - "https://source.unsplash.com/random/800x600/?img=1") - - # check if folder exists and create it if not - if not os.path.exists("content/uploads/img"): - - os.makedirs("content/uploads/img") - - with open(f"content/uploads/img/{name_in_disk}", "wb") as f: - f.write(image.content) - - course.thumbnail = name_in_disk - - course = CourseInDB(**course.dict()) - await courses.insert_one(course.dict()) - - # create chapters - for i in range(0, 5): - coursechapter = CourseChapter( - name=fake_multilang.unique.sentence(), - description=fake_multilang.unique.text(), - activities=[], - ) - coursechapter = await create_coursechapter(request,coursechapter, course_id, current_user) - if coursechapter: - # create activities - for i in range(0, 5): - activity = Activity( - name=fake_multilang.unique.sentence(), - type="dynamic", - content={}, - ) - activity = await create_activity(request,activity, "org_test", coursechapter['coursechapter_id'], current_user) diff --git a/apps/api/src/services/install/install.py b/apps/api/src/services/install/install.py index 55a6e362..b4538b28 100644 --- a/apps/api/src/services/install/install.py +++ b/apps/api/src/services/install/install.py @@ -1,35 +1,15 @@ from datetime import datetime from uuid import uuid4 -from fastapi import HTTPException, Request, status -from pydantic import BaseModel -import requests +from fastapi import HTTPException, Request +from sqlalchemy import desc +from sqlmodel import Session, select +from src.db.install import Install, InstallRead +from src.db.organizations import Organization, OrganizationCreate +from src.db.roles import Permission, Rights, Role, RoleTypeEnum +from src.db.user_organizations import UserOrganization +from src.db.users import User, UserCreate, UserRead from config.config import get_learnhouse_config from src.security.security import security_hash_password -from src.services.courses.activities.activities import Activity, create_activity -from src.services.courses.chapters import create_coursechapter, CourseChapter -from src.services.courses.courses import CourseInDB - -from src.services.orgs.schemas.orgs import Organization, OrganizationInDB -from faker import Faker - - -from src.services.roles.schemas.roles import Elements, Permission, RoleInDB -from src.services.users.schemas.users import ( - PublicUser, - User, - UserInDB, - UserOrganization, - UserRolesInOrganization, - UserWithPassword, -) - - -class InstallInstance(BaseModel): - install_id: str - created_date: str - updated_date: str - step: int - data: dict async def isInstallModeEnabled(): @@ -44,37 +24,48 @@ async def isInstallModeEnabled(): ) -async def create_install_instance(request: Request, data: dict): - installs = request.app.db["installs"] +async def create_install_instance(request: Request, data: dict, db_session: Session): + install = Install.from_orm(data) - # get install_id - install_id = str(f"install_{uuid4()}") - created_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - updated_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - step = 1 + # complete install instance + install.install_uuid = str(f"install_{uuid4()}") + install.update_date = str(datetime.now()) + install.creation_date = str(datetime.now()) - # create install - install = InstallInstance( - install_id=install_id, - created_date=created_date, - updated_date=updated_date, - step=step, - data=data, - ) + # insert install instance + db_session.add(install) - # insert install - installs.insert_one(install.dict()) + # commit changes + db_session.commit() + + # refresh install instance + db_session.refresh(install) + + install = InstallRead.from_orm(install) return install -async def get_latest_install_instance(request: Request): - installs = request.app.db["installs"] +async def get_latest_install_instance(request: Request, db_session: Session): + statement = select(Install).order_by(desc(Install.creation_date)).limit(1) + install = db_session.exec(statement).first() - # get latest created install instance using find_one - install = await installs.find_one( - sort=[("created_date", -1)], limit=1, projection={"_id": 0} - ) + if install is None: + raise HTTPException( + status_code=404, + detail="No install instance found", + ) + + install = InstallRead.from_orm(install) + + return install + + +async def update_install_instance( + request: Request, data: dict, step: int, db_session: Session +): + statement = select(Install).order_by(desc(Install.creation_date)).limit(1) + install = db_session.exec(statement).first() if install is None: raise HTTPException( @@ -82,37 +73,18 @@ async def get_latest_install_instance(request: Request): detail="No install instance found", ) - else: - install = InstallInstance(**install) + install.step = step + install.data = data - return install + # commit changes + db_session.commit() + # refresh install instance + db_session.refresh(install) -async def update_install_instance(request: Request, data: dict, step: int): - installs = request.app.db["installs"] + install = InstallRead.from_orm(install) - # get latest created install - install = await installs.find_one( - sort=[("created_date", -1)], limit=1, projection={"_id": 0} - ) - - if install is None: - return None - - else: - # update install - install["data"] = data - install["step"] = step - install["updated_date"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # update install - await installs.update_one( - {"install_id": install["install_id"]}, {"$set": install} - ) - - install = InstallInstance(**install) - - return install + return install ############################################################################################################ @@ -121,24 +93,34 @@ async def update_install_instance(request: Request, data: dict, step: int): # Install Default roles -async def install_default_elements(request: Request, data: dict): - roles = request.app.db["roles"] +async def install_default_elements(request: Request, data: dict, db_session: Session): + # remove all default roles + statement = select(Role).where(Role.role_type == RoleTypeEnum.TYPE_GLOBAL) + roles = db_session.exec(statement).all() - # check if default roles ADMIN_ROLE and USER_ROLE already exist - admin_role = await roles.find_one({"role_id": "role_admin"}) - user_role = await roles.find_one({"role_id": "role_member"}) + for role in roles: + db_session.delete(role) - if admin_role is not None or user_role is not None: + db_session.commit() + + # Check if default roles already exist + statement = select(Role).where(Role.role_type == RoleTypeEnum.TYPE_GLOBAL) + roles = db_session.exec(statement).all() + + if roles and len(roles) == 3: raise HTTPException( - status_code=400, + status_code=409, detail="Default roles already exist", ) - # get default roles - ADMIN_ROLE = RoleInDB( - name="Admin Role", - description="This role grants all permissions to the user", - elements=Elements( + # Create default roles + role_global_admin = Role( + name="Admin", + description="Standard Admin Role", + id=1, + role_type=RoleTypeEnum.TYPE_GLOBAL, + role_uuid="role_global_admin", + rights=Rights( courses=Permission( action_create=True, action_read=True, @@ -151,12 +133,6 @@ async def install_default_elements(request: Request, data: dict): action_update=True, action_delete=True, ), - houses=Permission( - action_create=True, - action_read=True, - action_update=True, - action_delete=True, - ), collections=Permission( action_create=True, action_read=True, @@ -182,16 +158,65 @@ async def install_default_elements(request: Request, data: dict): action_delete=True, ), ), - org_id="*", - role_id="role_admin", - created_at=str(datetime.now()), - updated_at=str(datetime.now()), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) - USER_ROLE = RoleInDB( - name="Member Role", - description="This role grants read-only permissions to the user", - elements=Elements( + role_global_maintainer = Role( + name="Maintainer", + description="Standard Maintainer Role", + id=2, + role_type=RoleTypeEnum.TYPE_GLOBAL, + role_uuid="role_global_maintainer", + rights=Rights( + courses=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + users=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + collections=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + organizations=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + coursechapters=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + activities=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), + ), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + role_global_user = Role( + name="User", + description="Standard User Role", + role_type=RoleTypeEnum.TYPE_GLOBAL, + role_uuid="role_global_user", + id=3, + rights=Rights( courses=Permission( action_create=False, action_read=True, @@ -199,13 +224,7 @@ async def install_default_elements(request: Request, data: dict): action_delete=False, ), users=Permission( - action_create=False, - action_read=True, - action_update=False, - action_delete=False, - ), - houses=Permission( - action_create=False, + action_create=True, action_read=True, action_update=False, action_delete=False, @@ -235,185 +254,122 @@ async def install_default_elements(request: Request, data: dict): action_delete=False, ), ), - org_id="*", - role_id="role_member", - created_at=str(datetime.now()), - updated_at=str(datetime.now()), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), ) - try: - # insert default roles - await roles.insert_many([USER_ROLE.dict(), ADMIN_ROLE.dict()]) - return True + # Serialize rights to JSON + role_global_admin.rights = role_global_admin.rights.dict() # type: ignore + role_global_maintainer.rights = role_global_maintainer.rights.dict() # type: ignore + role_global_user.rights = role_global_user.rights.dict() # type: ignore - except Exception: - raise HTTPException( - status_code=400, - detail="Error while inserting default roles", - ) + # Insert roles in DB + db_session.add(role_global_admin) + db_session.add(role_global_maintainer) + db_session.add(role_global_user) + + # commit changes + db_session.commit() + + # refresh roles + db_session.refresh(role_global_admin) + + return True # Organization creation async def install_create_organization( - request: Request, - org_object: Organization, + request: Request, org_object: OrganizationCreate, db_session: Session ): - orgs = request.app.db["organizations"] - request.app.db["users"] + org = Organization.from_orm(org_object) - # find if org already exists using name + # Complete the org object + org.org_uuid = f"org_{uuid4()}" + org.creation_date = str(datetime.now()) + org.update_date = str(datetime.now()) - isOrgAvailable = await orgs.find_one({"slug": org_object.slug.lower()}) + db_session.add(org) + db_session.commit() + db_session.refresh(org) - if isOrgAvailable: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Organization slug already exists", - ) - - # generate org_id with uuid4 - org_id = str(f"org_{uuid4()}") - - org = OrganizationInDB(org_id=org_id, **org_object.dict()) - - org_in_db = await orgs.insert_one(org.dict()) - - if not org_in_db: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) - - return org.dict() + return org async def install_create_organization_user( - request: Request, user_object: UserWithPassword, org_slug: str + request: Request, user_object: UserCreate, org_slug: str, db_session: Session ): - users = request.app.db["users"] + user = User.from_orm(user_object) - isUsernameAvailable = await users.find_one({"username": user_object.username}) - isEmailAvailable = await users.find_one({"email": user_object.email}) + # Complete the user object + user.user_uuid = f"user_{uuid4()}" + user.password = await security_hash_password(user_object.password) + user.email_verified = False + user.creation_date = str(datetime.now()) + user.update_date = str(datetime.now()) - if isUsernameAvailable: + # Verifications + + # Check if Organization exists + statement = select(Organization).where(Organization.slug == org_slug) + org = db_session.exec(statement) + + if not org.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Username already exists" + status_code=400, + detail="Organization does not exist", ) - if isEmailAvailable: + # Username + statement = select(User).where(User.username == user.username) + result = db_session.exec(statement) + + if result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Email already exists" + status_code=400, + detail="Username already exists", ) - # Generate user_id with uuid4 - user_id = str(f"user_{uuid4()}") + # Email + statement = select(User).where(User.email == user.email) + result = db_session.exec(statement) - # Set the username & hash the password - user_object.username = user_object.username.lower() - user_object.password = await security_hash_password(user_object.password) - - # Get org_id from org_slug - orgs = request.app.db["organizations"] - - # Check if the org exists - isOrgExists = await orgs.find_one({"slug": org_slug}) - - # If the org does not exist, raise an error - if not isOrgExists: + if result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="You are trying to create a user in an organization that does not exist", + status_code=400, + detail="Email already exists", ) - org_id = isOrgExists["org_id"] + # Exclude unset values + user_data = user.dict(exclude_unset=True) + for key, value in user_data.items(): + setattr(user, key, value) - # Create initial orgs list with the org_id passed in - orgs = [UserOrganization(org_id=org_id, org_role="owner")] + # Add user to database + db_session.add(user) + db_session.commit() + db_session.refresh(user) - # Give role - roles = [UserRolesInOrganization(role_id="role_admin", org_id=org_id)] + - # Create the user - user = UserInDB( - user_id=user_id, + # get org id + statement = select(Organization).where(Organization.slug == org_slug) + org = db_session.exec(statement) + org = org.first() + org_id = org.id if org else 0 + + # Link user and organization + user_organization = UserOrganization( + user_id=user.id if user.id else 0, + org_id=org_id or 0, + role_id=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), - orgs=orgs, - roles=roles, - **user_object.dict(), ) - # Insert the user into the database - await users.insert_one(user.dict()) + db_session.add(user_organization) + db_session.commit() + db_session.refresh(user_organization) - return User(**user.dict()) + user = UserRead.from_orm(user) - -async def create_sample_data(org_slug: str, username: str, request: Request): - Faker(["en_US"]) - fake_multilang = Faker( - ["en_US", "de_DE", "ja_JP", "es_ES", "it_IT", "pt_BR", "ar_PS"] - ) - - users = request.app.db["users"] - orgs = request.app.db["organizations"] - user = await users.find_one({"username": username}) - org = await orgs.find_one({"slug": org_slug.lower()}) - user_id = user["user_id"] - org_id = org["org_id"] - - current_user = PublicUser(**user) - - for i in range(0, 5): - # get image in BinaryIO format from unsplash and save it to disk - image = requests.get("https://source.unsplash.com/random/800x600") - with open("thumbnail.jpg", "wb") as f: - f.write(image.content) - - course_id = f"course_{uuid4()}" - course = CourseInDB( - name=fake_multilang.unique.sentence(), - description=fake_multilang.unique.text(), - mini_description=fake_multilang.unique.text(), - thumbnail="thumbnail", - org_id=org_id, - learnings=[fake_multilang.unique.sentence() for i in range(0, 5)], - public=True, - chapters=[], - course_id=course_id, - creationDate=str(datetime.now()), - updateDate=str(datetime.now()), - authors=[user_id], - chapters_content=[], - ) - - courses = request.app.db["courses"] - - course = CourseInDB(**course.dict()) - await courses.insert_one(course.dict()) - - # create chapters - for i in range(0, 5): - coursechapter = CourseChapter( - name=fake_multilang.unique.sentence(), - description=fake_multilang.unique.text(), - activities=[], - ) - coursechapter = await create_coursechapter( - request, coursechapter, course_id, current_user - ) - if coursechapter: - # create activities - for i in range(0, 5): - activity = Activity( - name=fake_multilang.unique.sentence(), - type="dynamic", - content={}, - ) - activity = await create_activity( - request, - activity, - org_id, - coursechapter["coursechapter_id"], - current_user, - ) + return user diff --git a/apps/api/src/services/orgs/logos.py b/apps/api/src/services/orgs/logos.py index 46108cf9..09fe048a 100644 --- a/apps/api/src/services/orgs/logos.py +++ b/apps/api/src/services/orgs/logos.py @@ -3,13 +3,13 @@ from uuid import uuid4 from src.services.utils.upload_content import upload_content -async def upload_org_logo(logo_file, org_id): +async def upload_org_logo(logo_file, org_uuid): contents = logo_file.file.read() name_in_disk = f"{uuid4()}.{logo_file.filename.split('.')[-1]}" await upload_content( "logos", - org_id, + org_uuid, contents, name_in_disk, ) diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index a65a4f63..dec8f7ff 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -1,230 +1,287 @@ -import json +from datetime import datetime from typing import Literal from uuid import uuid4 +from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) -from src.services.orgs.logos import upload_org_logo -from src.services.orgs.schemas.orgs import ( +from src.db.users import AnonymousUser, PublicUser +from src.db.user_organizations import UserOrganization +from src.db.organizations import ( Organization, - OrganizationInDB, - PublicOrganization, + OrganizationCreate, + OrganizationRead, + OrganizationUpdate, ) -from src.services.users.schemas.users import UserOrganization -from src.services.users.users import PublicUser +from src.services.orgs.logos import upload_org_logo from fastapi import HTTPException, UploadFile, status, Request -async def get_organization(request: Request, org_id: str): - orgs = request.app.db["organizations"] +async def get_organization( + request: Request, + org_id: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) - org = await orgs.find_one({"org_id": org_id}) + org = result.first() if not org: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" + status_code=404, + detail="Organization not found", ) - org = PublicOrganization(**org) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + org = OrganizationRead.from_orm(org) + return org -async def get_organization_by_slug(request: Request, org_slug: str): - orgs = request.app.db["organizations"] +async def get_organization_by_slug( + request: Request, + org_slug: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + statement = select(Organization).where(Organization.slug == org_slug) + result = db_session.exec(statement) - org = await orgs.find_one({"slug": org_slug}) + org = result.first() if not org: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" + status_code=404, + detail="Organization not found", ) - org = PublicOrganization(**org) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + org = OrganizationRead.from_orm(org) + return org async def create_org( - request: Request, org_object: Organization, current_user: PublicUser + request: Request, + org_object: OrganizationCreate, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): - orgs = request.app.db["organizations"] - user = request.app.db["users"] + statement = select(Organization).where(Organization.slug == org_object.slug) + result = db_session.exec(statement) - # find if org already exists using name - isOrgAvailable = await orgs.find_one({"slug": org_object.slug}) + org = result.first() - if isOrgAvailable: + if org: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization already exists", + ) + + org = Organization.from_orm(org_object) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + + # Complete the org object + org.org_uuid = f"org_{uuid4()}" + org.creation_date = str(datetime.now()) + org.update_date = str(datetime.now()) + + db_session.add(org) + db_session.commit() + db_session.refresh(org) + + # Link user to org + user_org = UserOrganization( + user_id=int(current_user.id), + org_id=int(org.id if org.id else 0), + role_id=1, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(user_org) + db_session.commit() + db_session.refresh(user_org) + + return OrganizationRead.from_orm(org) + + +async def update_org( + request: Request, + org_object: OrganizationUpdate, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization slug not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Verify if the new slug is already in use + statement = select(Organization).where(Organization.slug == org_object.slug) + result = db_session.exec(statement) + + slug_available = result.first() + + if slug_available and slug_available.id != org_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Organization slug already exists", ) - # generate org_id with uuid4 - org_id = str(f"org_{uuid4()}") + # Update only the fields that were passed in + for var, value in vars(org_object).items(): + if value is not None: + setattr(org, var, value) - # force lowercase slug - org_object.slug = org_object.slug.lower() + # Complete the org object + org.update_date = str(datetime.now()) - org = OrganizationInDB( - org_id=org_id, **org_object.dict() - ) + db_session.add(org) + db_session.commit() + db_session.refresh(org) - org_in_db = await orgs.insert_one(org.dict()) + org = OrganizationRead.from_orm(org) - user_organization: UserOrganization = UserOrganization( - org_id=org_id, org_role="owner" - ) - - # add org to user - await user.update_one( - {"user_id": current_user.user_id}, - {"$addToSet": {"orgs": user_organization.dict()}}, - ) - - # add role admin to org - await user.update_one( - {"user_id": current_user.user_id}, - {"$addToSet": {"roles": {"org_id": org_id, "role_id": "role_admin"}}}, - ) - - if not org_in_db: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) - - return org.dict() - - -async def update_org( - request: Request, org_object: Organization, org_id: str, current_user: PublicUser -): - # verify org rights - await verify_org_rights(request, org_id, current_user, "update") - - orgs = request.app.db["organizations"] - - await orgs.find_one({"org_id": org_id}) - - updated_org = OrganizationInDB(org_id=org_id, **org_object.dict()) - - # update org - await orgs.update_one({"org_id": org_id}, {"$set": updated_org.dict()}) - - return updated_org.dict() + return org async def update_org_logo( - request: Request, logo_file: UploadFile, org_id: str, current_user: PublicUser + request: Request, + logo_file: UploadFile, + org_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): - # verify org rights - await verify_org_rights(request, org_id, current_user, "update") + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) - orgs = request.app.db["organizations"] + org = result.first() - await orgs.find_one({"org_id": org_id}) + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) - name_in_disk = await upload_org_logo(logo_file, org_id) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) - # update org - await orgs.update_one({"org_id": org_id}, {"$set": {"logo": name_in_disk}}) + # Upload logo + name_in_disk = await upload_org_logo(logo_file, org.org_uuid) + + # Update org + org.logo_image = name_in_disk + + # Complete the org object + org.update_date = str(datetime.now()) + + db_session.add(org) + db_session.commit() + db_session.refresh(org) return {"detail": "Logo updated"} -async def delete_org(request: Request, org_id: str, current_user: PublicUser): - await verify_org_rights(request, org_id, current_user, "delete") +async def delete_org( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) - orgs = request.app.db["organizations"] - - org = await orgs.find_one({"org_id": org_id}) + org = result.first() if not org: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" + status_code=404, + detail="Organization not found", ) - isDeleted = await orgs.delete_one({"org_id": org_id}) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) - # remove org from all users - users = request.app.db["users"] - await users.update_many({}, {"$pull": {"orgs": {"org_id": org_id}}}) + db_session.delete(org) + db_session.commit() - if isDeleted: - return {"detail": "Org deleted"} - else: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Unavailable database", - ) + # Delete links to org + statement = select(UserOrganization).where(UserOrganization.org_id == org_id) + result = db_session.exec(statement) + + user_orgs = result.all() + + for user_org in user_orgs: + db_session.delete(user_org) + db_session.commit() + + db_session.refresh(org) + + return {"detail": "Organization deleted"} async def get_orgs_by_user( - request: Request, user_id: str, page: int = 1, limit: int = 10 -): - orgs = request.app.db["organizations"] - user = request.app.db["users"] - - if user_id == "anonymous": - # raise error - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User not logged in" + request: Request, + db_session: Session, + user_id: str, + page: int = 1, + limit: int = 10, +) -> list[Organization]: + statement = ( + select(Organization) + .join(UserOrganization) + .where( + Organization.id == UserOrganization.org_id, + UserOrganization.user_id == user_id, ) - - # get user orgs - user_orgs = await user.find_one({"user_id": user_id}) - - org_ids: list[UserOrganization] = [] - - for org in user_orgs["orgs"]: - if ( - org["org_role"] == "owner" - or org["org_role"] == "editor" - or org["org_role"] == "member" - ): - org_ids.append(org["org_id"]) - - # find all orgs where org_id is in org_ids array - - all_orgs = ( - orgs.find({"org_id": {"$in": org_ids}}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(100) ) + result = db_session.exec(statement) - return [ - json.loads(json.dumps(org, default=str)) - for org in await all_orgs.to_list(length=100) - ] + orgs = result.all() + + return orgs -#### Security #################################################### +## 🔒 RBAC Utils ## -async def verify_org_rights( +async def rbac_check( request: Request, org_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], + db_session: Session, ): - orgs = request.app.db["organizations"] - users = request.app.db["users"] + # Organizations are readable by anyone + if action == "read": + return True - user = await users.find_one({"user_id": current_user.user_id}) + else: + await authorization_verify_if_user_is_anon(current_user.id) - org = await orgs.find_one({"org_id": org_id}) - - if not org: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, org_id, db_session ) - await authorization_verify_if_user_is_anon(current_user.user_id) - await authorization_verify_based_on_roles( - request, current_user.user_id, action, user["roles"], org_id - ) - - -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/orgs/schemas/__init__.py b/apps/api/src/services/orgs/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/orgs/schemas/orgs.py b/apps/api/src/services/orgs/schemas/orgs.py deleted file mode 100644 index 56531357..00000000 --- a/apps/api/src/services/orgs/schemas/orgs.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional -from pydantic import BaseModel - -#### Classes #################################################### - - -class Organization(BaseModel): - name: str - description: str - email: str - slug: str - logo: Optional[str] - default: Optional[bool] = False - - -class OrganizationInDB(Organization): - org_id: str - - -class PublicOrganization(Organization): - name: str - description: str - email: str - slug: str - org_id: str - - def __getitem__(self, item): - return getattr(self, item) diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index cc61c452..a5d46253 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -1,127 +1,141 @@ from typing import Literal from uuid import uuid4 -from src.security.rbac.rbac import authorization_verify_if_user_is_anon -from src.services.roles.schemas.roles import Role, RoleInDB -from src.services.users.schemas.users import PublicUser -from fastapi import HTTPException, status, Request +from sqlmodel import Session, select +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) +from src.db.users import AnonymousUser, PublicUser +from src.db.roles import Role, RoleCreate, RoleRead, RoleUpdate +from fastapi import HTTPException, Request from datetime import datetime -async def create_role(request: Request, role_object: Role, current_user: PublicUser): - roles = request.app.db["roles"] +async def create_role( + request: Request, + db_session: Session, + role_object: RoleCreate, + current_user: PublicUser, +): + role = Role.from_orm(role_object) - await verify_user_permissions_on_roles(request, current_user, "create", None) + # RBAC check + await rbac_check(request, current_user, "create", "role_xxx", db_session) - # create the role object in the database and return the object - role_id = "role_" + str(uuid4()) + # Complete the role object + role.role_uuid = f"role_{uuid4()}" + role.creation_date = str(datetime.now()) + role.update_date = str(datetime.now()) - role = RoleInDB( - role_id=role_id, - created_at=str(datetime.now()), - updated_at=str(datetime.now()), - **role_object.dict() - ) + db_session.add(role) + db_session.commit() + db_session.refresh(role) - await roles.insert_one(role.dict()) + role = RoleRead(**role.dict()) return role -async def read_role(request: Request, role_id: str, current_user: PublicUser): - roles = request.app.db["roles"] +async def read_role( + request: Request, db_session: Session, role_id: str, current_user: PublicUser +): + statement = select(Role).where(Role.id == role_id) + result = db_session.exec(statement) - await verify_user_permissions_on_roles(request, current_user, "read", role_id) + role = result.first() - role = RoleInDB(**await roles.find_one({"role_id": role_id})) + if not role: + raise HTTPException( + status_code=404, + detail="Role not found", + ) + + # RBAC check + await rbac_check(request, current_user, "read", role.role_uuid, db_session) + + role = RoleRead(**role.dict()) return role async def update_role( - request: Request, role_id: str, role_object: Role, current_user: PublicUser + request: Request, + db_session: Session, + role_object: RoleUpdate, + current_user: PublicUser, ): - roles = request.app.db["roles"] + statement = select(Role).where(Role.id == role_object.role_id) + result = db_session.exec(statement) - await verify_user_permissions_on_roles(request, current_user, "update", role_id) + role = result.first() - role_object.updated_at = datetime.now() - - # Update the role object in the database and return the object - updated_role = RoleInDB( - **await roles.find_one_and_update( - {"role_id": role_id}, {"$set": role_object.dict()}, return_document=True + if not role: + raise HTTPException( + status_code=404, + detail="Role not found", ) + + # RBAC check + await rbac_check(request, current_user, "update", role.role_uuid, db_session) + + # Complete the role object + role.update_date = str(datetime.now()) + + # Remove the role_id from the role_object + del role_object.role_id + + # Update only the fields that were passed in + for var, value in vars(role_object).items(): + if value is not None: + setattr(role, var, value) + + db_session.add(role) + db_session.commit() + db_session.refresh(role) + + role = RoleRead(**role.dict()) + + return role + + +async def delete_role( + request: Request, db_session: Session, role_id: str, current_user: PublicUser +): + # RBAC check + await rbac_check(request, current_user, "delete", role_id, db_session) + + statement = select(Role).where(Role.id == role_id) + result = db_session.exec(statement) + + role = result.first() + + if not role: + raise HTTPException( + status_code=404, + detail="Role not found", + ) + + db_session.delete(role) + db_session.commit() + + return "Role deleted" + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + role_uuid: str, + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, role_uuid, db_session ) - return updated_role - -async def delete_role(request: Request, role_id: str, current_user: PublicUser): - roles = request.app.db["roles"] - - await verify_user_permissions_on_roles(request, current_user, "delete", role_id) - - # Delete the role object in the database and return the object - deleted_role = RoleInDB(**await roles.find_one_and_delete({"role_id": role_id})) - - return deleted_role - - -#### Security #################################################### - - -async def verify_user_permissions_on_roles( - request: Request, - current_user: PublicUser, - action: Literal["create", "read", "update", "delete"], - role_id: str | None, -): - request.app.db["users"] - roles = request.app.db["roles"] - - # If current user is not authenticated - - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Roles : Not authenticated" - ) - - await authorization_verify_if_user_is_anon(current_user.user_id) - - if action == "create": - if "owner" in [org.org_role for org in current_user.orgs]: - return True - - if role_id is not None: - role = RoleInDB(**await roles.find_one({"role_id": role_id})) - - if action == "read": - if "owner" in [org.org_role for org in current_user.orgs]: - return True - - for org in current_user.orgs: - if org.org_id == role.org_id: - return True - - if action == "update": - for org in current_user.orgs: - # If the user is an owner of the organization - if org.org_id == role.org_id: - if org.org_role == "owner" or org.org_role == "editor": - return True - # Can't update a global role - if role.org_id == "*": - return False - - if action == "delete": - for org in current_user.orgs: - # If the user is an owner of the organization - if org.org_id == role.org_id: - if org.org_role == "owner": - return True - # Can't delete a global role - if role.org_id == "*": - return False - - -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/roles/schemas/__init__.py b/apps/api/src/services/roles/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/roles/schemas/roles.py b/apps/api/src/services/roles/schemas/roles.py deleted file mode 100644 index 18b0c34c..00000000 --- a/apps/api/src/services/roles/schemas/roles.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Literal -from pydantic import BaseModel - - -# Database Models - -class Permission(BaseModel): - action_create: bool - action_read: bool - action_update: bool - action_delete: bool - - def __getitem__(self, item): - return getattr(self, item) - - -class Elements(BaseModel): - courses: Permission - users: Permission - houses: Permission - collections: Permission - organizations: Permission - coursechapters: Permission - activities: Permission - - def __getitem__(self, item): - return getattr(self, item) - - -class Role(BaseModel): - name: str - description: str - elements : Elements - org_id: str | Literal["*"] - - -class RoleInDB(Role): - role_id: str - created_at: str - updated_at: str - diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 08e38700..973575a7 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -1,286 +1,423 @@ from datetime import datetime -from typing import List, Literal, Optional from uuid import uuid4 +from src.db.chapter_activities import ChapterActivity from fastapi import HTTPException, Request, status -from pydantic import BaseModel -from src.services.courses.chapters import get_coursechapters_meta -from src.services.orgs.orgs import PublicOrganization - -from src.services.users.users import PublicUser - -#### Classes #################################################### +from sqlmodel import Session, select +from src.db.activities import Activity +from src.db.courses import Course +from src.db.trail_runs import TrailRun, TrailRunRead +from src.db.trail_steps import TrailStep +from src.db.trails import Trail, TrailCreate, TrailRead +from src.db.users import PublicUser -class ActivityData(BaseModel): - activity_id: str - activity_type: str - data: Optional[dict] - - -class TrailCourse(BaseModel): - course_id: str - elements_type: Optional[Literal["course"]] = "course" - status: Optional[Literal["ongoing", "done", "closed"]] = "ongoing" - course_object: dict - masked: Optional[bool] = False - activities_marked_complete: Optional[List[str]] - activities_data: Optional[List[ActivityData]] - progress: Optional[int] - - -class Trail(BaseModel): - status: Optional[Literal["ongoing", "done", "closed"]] = "ongoing" - masked: Optional[bool] = False - courses: Optional[List[TrailCourse]] - - -class TrailInDB(Trail): - trail_id: str - org_id: str - user_id: str - creationDate: str = datetime.now().isoformat() - updateDate: str = datetime.now().isoformat() - - -#### Classes #################################################### - - -async def create_trail( - request: Request, user: PublicUser, org_id: str, trail_object: Trail +async def create_user_trail( + request: Request, + user: PublicUser, + trail_object: TrailCreate, + db_session: Session, ) -> Trail: - trails = request.app.db["trails"] + statement = select(Trail).where(Trail.org_id == trail_object.org_id) + trail = db_session.exec(statement).first() - # get list of courses - if trail_object.courses: - courses = trail_object.courses - # get course ids - course_ids = [course.course_id for course in courses] - - # find if the user has already started the course - existing_trail = await trails.find_one( - {"user_id": user.user_id, "courses.course_id": {"$in": course_ids}} + if trail: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Trail already exists", ) - if existing_trail: - # update the status of the element with the matching course_id to "ongoing" - for element in existing_trail["courses"]: - if element["course_id"] in course_ids: - element["status"] = "ongoing" - # update the existing trail in the database - await trails.replace_one( - {"trail_id": existing_trail["trail_id"]}, existing_trail - ) - # create trail id - trail_id = f"trail_{uuid4()}" + trail = Trail.from_orm(trail_object) + + trail.creation_date = str(datetime.now()) + trail.update_date = str(datetime.now()) + trail.org_id = trail_object.org_id + trail.trail_uuid = str(f"trail_{uuid4()}") # create trail - trail = TrailInDB( - **trail_object.dict(), trail_id=trail_id, user_id=user.user_id, org_id=org_id - ) - - await trails.insert_one(trail.dict()) + db_session.add(trail) + db_session.commit() + db_session.refresh(trail) return trail -async def get_user_trail(request: Request, org_slug: str, user: PublicUser) -> Trail: - trails = request.app.db["trails"] - trail = await trails.find_one({"user_id": user.user_id}) +async def get_user_trails( + request: Request, + user: PublicUser, + db_session: Session, +) -> TrailRead: + statement = select(Trail).where(Trail.user_id == user.id) + trail = db_session.exec(statement).first() + if not trail: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found" ) - for element in trail["courses"]: - course_id = element["course_id"] - chapters_meta = await get_coursechapters_meta(request, course_id, user) - activities = chapters_meta["activities"] - num_activities = len(activities) - num_completed_activities = len(element.get("activities_marked_complete", [])) - element["progress"] = ( - round((num_completed_activities / num_activities) * 100, 2) - if num_activities > 0 - else 0 + statement = select(TrailRun).where(TrailRun.trail_id == trail.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + # Add course object and total activities in a course to trail runs + for trail_run in trail_runs: + statement = select(Course).where(Course.id == trail_run.course_id) + course = db_session.exec(statement).first() + trail_run.course = course + + # Add number of activities (steps) in a course + statement = select(ChapterActivity).where( + ChapterActivity.course_id == trail_run.course_id ) + course_total_steps = db_session.exec(statement) + # count number of activities in a this list + trail_run.course_total_steps = len(course_total_steps.all()) - return Trail(**trail) + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) + trail_steps = db_session.exec(statement).all() + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps -async def get_user_trail_with_orgslug( - request: Request, user: PublicUser, org_slug: str -) -> Trail: - trails = request.app.db["trails"] - orgs = request.app.db["organizations"] - courses_mongo = request.app.db["courses"] + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) - # get org_id from orgslug - org = await orgs.find_one({"slug": org_slug}) - - trail = await trails.find_one({"user_id": user.user_id, "org_id": org["org_id"]}) - - if not trail: - return Trail(masked=False, courses=[]) - - course_ids = [course["course_id"] for course in trail["courses"]] - - live_courses = await courses_mongo.find({"course_id": {"$in": course_ids}}).to_list( - length=None + trail_read = TrailRead( + **trail.dict(), + runs=trail_runs, ) - for course in trail["courses"]: - course_id = course["course_id"] + return trail_read - if course_id not in [course["course_id"] for course in live_courses]: - course["masked"] = True - continue - chapters_meta = await get_coursechapters_meta(request, course_id, user) - activities = chapters_meta["activities"] +async def check_trail_presence( + org_id: int, + user_id: int, + request: Request, + user: PublicUser, + db_session: Session, +): + statement = select(Trail).where(Trail.org_id == org_id, Trail.user_id == user.id) + trail = db_session.exec(statement).first() - # get course object without _id - course_object = await courses_mongo.find_one( - {"course_id": course_id}, {"_id": 0} + if not trail: + trail = await create_user_trail( + request, + user, + TrailCreate( + org_id=org_id, + user_id=user.id, + ), + db_session, ) + return trail - course["course_object"] = course_object - num_activities = len(activities) + return trail - num_completed_activities = len(course.get("activities_marked_complete", [])) - course["progress"] = ( - round((num_completed_activities / num_activities) * 100, 2) - if num_activities > 0 - else 0 + +async def get_user_trail_with_orgid( + request: Request, user: PublicUser, org_id: int, db_session: Session +) -> TrailRead: + + trail = await check_trail_presence( + org_id=org_id, + user_id=user.id, + request=request, + user=user, + db_session=db_session, + ) + + statement = select(TrailRun).where(TrailRun.trail_id == trail.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + # Add course object and total activities in a course to trail runs + for trail_run in trail_runs: + statement = select(Course).where(Course.id == trail_run.course_id) + course = db_session.exec(statement).first() + trail_run.course = course + + # Add number of activities (steps) in a course + statement = select(ChapterActivity).where( + ChapterActivity.course_id == trail_run.course_id ) + course_total_steps = db_session.exec(statement) + # count number of activities in a this list + trail_run.course_total_steps = len(course_total_steps.all()) - return Trail(**trail) + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) + trail_steps = db_session.exec(statement).all() + + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps + + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) + + trail_read = TrailRead( + **trail.dict(), + runs=trail_runs, + ) + + return trail_read async def add_activity_to_trail( - request: Request, user: PublicUser, course_id: str, org_slug: str, activity_id: str -) -> Trail: - trails = request.app.db["trails"] - orgs = request.app.db["organizations"] - courseid = "course_" + course_id - activityid = "activity_" + activity_id + request: Request, + user: PublicUser, + activity_uuid: str, + db_session: Session, +) -> TrailRead: + # Look for the activity + statement = select(Activity).where(Activity.activity_uuid == activity_uuid) + activity = db_session.exec(statement).first() - # get org_id from orgslug - org = await orgs.find_one({"slug": org_slug}) - org_id = org["org_id"] - - # find a trail with the user_id and course_id in the courses array - trail = await trails.find_one( - {"user_id": user.user_id, "courses.course_id": courseid, "org_id": org_id} - ) - - if user.user_id == "anonymous": + if not activity: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Anonymous users cannot add activity to trail", + status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found" ) - if not trail: - return Trail(masked=False, courses=[]) + statement = select(Course).where(Course.id == activity.course_id) + course = db_session.exec(statement).first() - # if a trail has course_id in the courses array, then add the activity_id to the activities_marked_complete array - for element in trail["courses"]: - if element["course_id"] == courseid: - if "activities_marked_complete" in element: - # check if activity_id is already in the array - if activityid not in element["activities_marked_complete"]: - element["activities_marked_complete"].append(activityid) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Activity already marked complete", - ) - else: - element["activities_marked_complete"] = [activity_id] + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) - # modify trail object - await trails.replace_one({"trail_id": trail["trail_id"]}, trail) + trail = await check_trail_presence( + org_id=course.org_id, + user_id=user.id, + request=request, + user=user, + db_session=db_session, + ) - return Trail(**trail) + statement = select(TrailRun).where( + TrailRun.trail_id == trail.id, TrailRun.course_id == course.id + ) + trailrun = db_session.exec(statement).first() + + if not trailrun: + trailrun = TrailRun( + trail_id=trail.id if trail.id is not None else 0, + course_id=course.id if course.id is not None else 0, + org_id=course.org_id, + user_id=user.id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + db_session.add(trailrun) + db_session.commit() + db_session.refresh(trailrun) + + statement = select(TrailStep).where( + TrailStep.trailrun_id == trailrun.id, TrailStep.activity_id == activity.id + ) + trailstep = db_session.exec(statement).first() + + if not trailstep: + trailstep = TrailStep( + trailrun_id=trailrun.id if trailrun.id is not None else 0, + activity_id=activity.id if activity.id is not None else 0, + course_id=course.id if course.id is not None else 0, + trail_id=trail.id if trail.id is not None else 0, + org_id=course.org_id, + complete=False, + teacher_verified=False, + grade="", + user_id=user.id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + db_session.add(trailstep) + db_session.commit() + db_session.refresh(trailstep) + + statement = select(TrailRun).where(TrailRun.trail_id == trail.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) + trail_steps = db_session.exec(statement).all() + + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps + + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) + + trail_read = TrailRead( + **trail.dict(), + runs=trail_runs, + ) + + return trail_read async def add_course_to_trail( - request: Request, user: PublicUser, orgslug: str, course_id: str -) -> Trail: - trails = request.app.db["trails"] - orgs = request.app.db["organizations"] + request: Request, + user: PublicUser, + course_uuid: str, + db_session: Session, +) -> TrailRead: + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() - if user.user_id == "anonymous": + if not course: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Anonymous users cannot add activity to trail", + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" ) - org = await orgs.find_one({"slug": orgslug}) + # check if run already exists + statement = select(TrailRun).where(TrailRun.course_id == course.id) + trailrun = db_session.exec(statement).first() - org = PublicOrganization(**org) - - trail = await trails.find_one({"user_id": user.user_id, "org_id": org["org_id"]}) - - if not trail: - trail_to_insert = TrailInDB( - trail_id=f"trail_{uuid4()}", - user_id=user.user_id, - org_id=org["org_id"], - courses=[], + if trailrun: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="TrailRun already exists" ) - trail_to_insert = await trails.insert_one(trail_to_insert.dict()) - trail = await trails.find_one({"_id": trail_to_insert.inserted_id}) - - # check if course is already present in the trail - for element in trail["courses"]: - if element["course_id"] == course_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Course already present in the trail", - ) - - updated_trail = TrailCourse( - course_id=course_id, - activities_data=[], - activities_marked_complete=[], - progress=0, - course_object={}, - status="ongoing", - masked=False, + statement = select(Trail).where( + Trail.org_id == course.org_id, Trail.user_id == user.id ) - trail["courses"].append(updated_trail.dict()) - await trails.replace_one({"trail_id": trail["trail_id"]}, trail) - return Trail(**trail) - - -async def remove_course_from_trail( - request: Request, user: PublicUser, orgslug: str, course_id: str -) -> Trail: - trails = request.app.db["trails"] - orgs = request.app.db["organizations"] - - if user.user_id == "anonymous": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Anonymous users cannot add activity to trail", - ) - - org = await orgs.find_one({"slug": orgslug}) - - org = PublicOrganization(**org) - trail = await trails.find_one({"user_id": user.user_id, "org_id": org["org_id"]}) + trail = db_session.exec(statement).first() if not trail: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found" ) - # check if course is already present in the trail + statement = select(TrailRun).where( + TrailRun.trail_id == trail.id, TrailRun.course_id == course.id + ) + trail_run = db_session.exec(statement).first() - for element in trail["courses"]: - if element["course_id"] == course_id: - trail["courses"].remove(element) - break + if not trail_run: + trail_run = TrailRun( + trail_id=trail.id if trail.id is not None else 0, + course_id=course.id if course.id is not None else 0, + org_id=course.org_id, + user_id=user.id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + db_session.add(trail_run) + db_session.commit() + db_session.refresh(trail_run) - await trails.replace_one({"trail_id": trail["trail_id"]}, trail) - return Trail(**trail) + statement = select(TrailRun).where(TrailRun.trail_id == trail.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) + trail_steps = db_session.exec(statement).all() + + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps + + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) + + trail_read = TrailRead( + **trail.dict(), + runs=trail_runs, + ) + + return trail_read + + +async def remove_course_from_trail( + request: Request, + user: PublicUser, + course_uuid: str, + db_session: Session, +) -> TrailRead: + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Course not found" + ) + + statement = select(Trail).where( + Trail.org_id == course.org_id, Trail.user_id == user.id + ) + trail = db_session.exec(statement).first() + + if not trail: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found" + ) + + statement = select(TrailRun).where( + TrailRun.trail_id == trail.id, TrailRun.course_id == course.id + ) + trail_run = db_session.exec(statement).first() + + if trail_run: + db_session.delete(trail_run) + db_session.commit() + + # Delete all trail steps for this course + statement = select(TrailStep).where(TrailStep.course_id == course.id) + trail_steps = db_session.exec(statement).all() + + for trail_step in trail_steps: + db_session.delete(trail_step) + db_session.commit() + + statement = select(TrailRun).where(TrailRun.trail_id == trail.id) + trail_runs = db_session.exec(statement).all() + + trail_runs = [ + TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0) + for trail_run in trail_runs + ] + + for trail_run in trail_runs: + statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id) + trail_steps = db_session.exec(statement).all() + + trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps] + trail_run.steps = trail_steps + + for trail_step in trail_steps: + statement = select(Course).where(Course.id == trail_step.course_id) + course = db_session.exec(statement).first() + trail_step.data = dict(course=course) + + trail_read = TrailRead( + **trail.dict(), + runs=trail_runs, + ) + + return trail_read diff --git a/apps/api/src/services/users/schemas/__init__.py b/apps/api/src/services/users/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/users/schemas/users.py b/apps/api/src/services/users/schemas/users.py deleted file mode 100644 index 2dea0e97..00000000 --- a/apps/api/src/services/users/schemas/users.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Literal -from pydantic import BaseModel - - -class UserOrganization(BaseModel): - org_id: str - org_role: Literal['owner', 'editor', 'member'] - - def __getitem__(self, item): - return getattr(self, item) - -class UserRolesInOrganization(BaseModel): - org_id: str - role_id: str - - def __getitem__(self, item): - return getattr(self, item) - - - -class User(BaseModel): - username: str - email: str - full_name: str | None = None - avatar_url: str | None = None - bio: str | None = None - - - -class UserWithPassword(User): - password: str - - -class UserInDB(User): - user_id: str - password: str - verified: bool | None = False - disabled: bool | None = False - orgs: list[UserOrganization] = [] - roles: list[UserRolesInOrganization] = [] - creation_date: str - update_date: str - - def __getitem__(self, item): - return getattr(self, item) - - - - -class PublicUser(User): - user_id: str - orgs: list[UserOrganization] = [] - roles: list[UserRolesInOrganization] = [] - creation_date: str - update_date: str - -class AnonymousUser(BaseModel): - user_id: str = "anonymous" - username: str = "anonymous" - roles: list[UserRolesInOrganization] = [ - UserRolesInOrganization(org_id="anonymous", role_id="role_anonymous") - ] - - - -# Forms #################################################### - -class PasswordChangeForm(BaseModel): - old_password: str - new_password: str diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 9aba3b2c..4a9c5ee6 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -2,214 +2,347 @@ from datetime import datetime from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request, status +from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles, + authorization_verify_based_on_roles_and_authorship, authorization_verify_if_user_is_anon, ) -from src.security.security import security_hash_password, security_verify_password -from src.services.users.schemas.users import ( - PasswordChangeForm, +from src.db.organizations import Organization +from src.db.users import ( + AnonymousUser, PublicUser, User, - UserOrganization, - UserRolesInOrganization, - UserWithPassword, - UserInDB, + UserCreate, + UserRead, + UserUpdate, + UserUpdatePassword, ) +from src.db.user_organizations import UserOrganization +from src.security.security import security_hash_password, security_verify_password async def create_user( request: Request, - current_user: PublicUser | None, - user_object: UserWithPassword, - org_slug: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_object: UserCreate, + org_id: int, ): - users = request.app.db["users"] + user = User.from_orm(user_object) - isUsernameAvailable = await users.find_one({"username": user_object.username}) - isEmailAvailable = await users.find_one({"email": user_object.email}) + # RBAC check + await rbac_check(request, current_user, "create", "user_x", db_session) - if isUsernameAvailable: + # Complete the user object + user.user_uuid = f"user_{uuid4()}" + user.password = await security_hash_password(user_object.password) + user.email_verified = False + user.creation_date = str(datetime.now()) + user.update_date = str(datetime.now()) + + # Verifications + + # Check if Organization exists + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + if not result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Username already exists" + status_code=400, + detail="Organization does not exist", ) - if isEmailAvailable: + # Username + statement = select(User).where(User.username == user.username) + result = db_session.exec(statement) + + if result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Email already exists" + status_code=400, + detail="Username already exists", ) - # Generate user_id with uuid4 - user_id = str(f"user_{uuid4()}") + # Email + statement = select(User).where(User.email == user.email) + result = db_session.exec(statement) - # Check if the requesting user is authenticated - if current_user is not None: - # Verify rights - await verify_user_rights_on_user(request, current_user, "create", user_id) - - # Set the username & hash the password - user_object.username = user_object.username.lower() - user_object.password = await security_hash_password(user_object.password) - - # Get org_id from org_slug - orgs = request.app.db["organizations"] - - # Check if the org exists - isOrgExists = await orgs.find_one({"slug": org_slug}) - - # If the org does not exist, raise an error - if not isOrgExists and (org_slug != "None"): + if result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="You are trying to create a user in an organization that does not exist", + status_code=400, + detail="Email already exists", ) - - org_id = isOrgExists["org_id"] if org_slug != "None" else '' - # Create initial orgs list with the org_id passed in - orgs = ( - [UserOrganization(org_id=org_id, org_role="member")] - if org_slug != "None" - else [] - ) + # Exclude unset values + user_data = user.dict(exclude_unset=True) + for key, value in user_data.items(): + setattr(user, key, value) - # Give role - roles = ( - [UserRolesInOrganization(role_id="role_member", org_id=org_id)] - if org_slug != "None" - else [] - ) + # Add user to database + db_session.add(user) + db_session.commit() + db_session.refresh(user) - # Create the user - user = UserInDB( - user_id=user_id, + # Link user and organization + user_organization = UserOrganization( + user_id=user.id if user.id else 0, + org_id=int(org_id), + role_id=3, creation_date=str(datetime.now()), update_date=str(datetime.now()), - orgs=orgs, - roles=roles, - **user_object.dict(), ) - # Insert the user into the database - await users.insert_one(user.dict()) + db_session.add(user_organization) + db_session.commit() + db_session.refresh(user_organization) - return User(**user.dict()) + user = UserRead.from_orm(user) + + return user -async def read_user(request: Request, current_user: PublicUser, user_id: str): - users = request.app.db["users"] +async def create_user_without_org( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_object: UserCreate, +): + user = User.from_orm(user_object) - # Check if the user exists - isUserExists = await users.find_one({"user_id": user_id}) + # RBAC check + await rbac_check(request, current_user, "create", "user_x", db_session) - # Verify rights - await verify_user_rights_on_user(request, current_user, "read", user_id) + # Complete the user object + user.user_uuid = f"user_{uuid4()}" + user.password = await security_hash_password(user_object.password) + user.email_verified = False + user.creation_date = str(datetime.now()) + user.update_date = str(datetime.now()) - # If the user does not exist, raise an error - if not isUserExists: + # Verifications + + # Username + statement = select(User).where(User.username == user.username) + result = db_session.exec(statement) + + if result.first(): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" + status_code=400, + detail="Username already exists", ) - return User(**isUserExists) + # Email + statement = select(User).where(User.email == user.email) + result = db_session.exec(statement) + + if result.first(): + raise HTTPException( + status_code=400, + detail="Email already exists", + ) + + # Exclude unset values + user_data = user.dict(exclude_unset=True) + for key, value in user_data.items(): + setattr(user, key, value) + + # Add user to database + db_session.add(user) + db_session.commit() + db_session.refresh(user) + + user = UserRead.from_orm(user) + + return user async def update_user( - request: Request, user_id: str, user_object: User, current_user: PublicUser + request: Request, + db_session: Session, + user_id: int, + current_user: PublicUser | AnonymousUser, + user_object: UserUpdate, ): - users = request.app.db["users"] + # Get user + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() - # Verify rights - await verify_user_rights_on_user(request, current_user, "update", user_id) - - isUserExists = await users.find_one({"user_id": user_id}) - isUsernameAvailable = await users.find_one({"username": user_object.username}) - isEmailAvailable = await users.find_one({"email": user_object.email}) - - if not isUserExists: + if not user: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" + status_code=400, + detail="User does not exist", ) - # okay if username is not changed - if isUserExists["username"] == user_object.username: - user_object.username = user_object.username.lower() + # RBAC check + await rbac_check(request, current_user, "update", user.user_uuid, db_session) - else: - if isUsernameAvailable: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Username already used" - ) + # Update user + user_data = user_object.dict(exclude_unset=True) + for key, value in user_data.items(): + setattr(user, key, value) - if isEmailAvailable: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Email already used" - ) + user.update_date = str(datetime.now()) - updated_user = {"$set": user_object.dict()} - users.update_one({"user_id": user_id}, updated_user) + # Update user in database + db_session.add(user) + db_session.commit() + db_session.refresh(user) - return User(**user_object.dict()) + user = UserRead.from_orm(user) + + return user async def update_user_password( request: Request, - current_user: PublicUser, - user_id: str, - password_change_form: PasswordChangeForm, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_id: int, + form: UserUpdatePassword, ): - users = request.app.db["users"] + # Get user + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() - isUserExists = await users.find_one({"user_id": user_id}) - - # Verify rights - await verify_user_rights_on_user(request, current_user, "update", user_id) - - if not isUserExists: + if not user: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" + status_code=400, + detail="User does not exist", ) - if not await security_verify_password( - password_change_form.old_password, isUserExists["password"] - ): + # RBAC check + await rbac_check(request, current_user, "update", user.user_uuid, db_session) + + if not await security_verify_password(form.old_password, user.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password" ) - new_password = await security_hash_password(password_change_form.new_password) + # Update user + user.password = await security_hash_password(form.new_password) + user.update_date = str(datetime.now()) - updated_user = {"$set": {"password": new_password}} - await users.update_one({"user_id": user_id}, updated_user) + # Update user in database + db_session.add(user) + db_session.commit() + db_session.refresh(user) - return {"detail": "Password updated"} + user = UserRead.from_orm(user) + + return user -async def delete_user(request: Request, current_user: PublicUser, user_id: str): - users = request.app.db["users"] +async def read_user_by_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_id: int, +): + # Get user + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() - isUserExists = await users.find_one({"user_id": user_id}) - - # Verify is user has permission to delete the user - await verify_user_rights_on_user(request, current_user, "delete", user_id) - - if not isUserExists: + if not user: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" + status_code=400, + detail="User does not exist", ) - await users.delete_one({"user_id": user_id}) + # RBAC check + await rbac_check(request, current_user, "read", user.user_uuid, db_session) - return {"detail": "User deleted"} + user = UserRead.from_orm(user) + + return user + + +async def read_user_by_uuid( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_uuid: str, +): + # Get user + statement = select(User).where(User.user_uuid == user_uuid) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # RBAC check + await rbac_check(request, current_user, "read", user.user_uuid, db_session) + + user = UserRead.from_orm(user) + + return user + + +async def authorize_user_action( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + ressource_uuid: str, + action: Literal["create", "read", "update", "delete"], +): + # Get user + statement = select(User).where(User.user_uuid == current_user.user_uuid) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # RBAC check + authorized = await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, ressource_uuid, db_session + ) + + if authorized: + return True + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not authorized to perform this action", + ) + + +async def delete_user_by_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_id: int, +): + # Get user + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # RBAC check + await rbac_check(request, current_user, "delete", user.user_uuid, db_session) + + # Delete user + db_session.delete(user) + db_session.commit() + + return "User deleted" # Utils & Security functions -async def security_get_user(request: Request, email: str): - users = request.app.db["users"] - - user = await users.find_one({"email": email}) +async def security_get_user(request: Request, db_session: Session, email: str) -> User: + # Check if user exists + statement = select(User).where(User.email == email) + user = db_session.exec(statement).first() if not user: raise HTTPException( @@ -217,105 +350,39 @@ async def security_get_user(request: Request, email: str): detail="User with Email does not exist", ) - return UserInDB(**user) + user = User(**user.dict()) - -async def get_userid_by_username(request: Request, username: str): - users = request.app.db["users"] - - user = await users.find_one({"username": username}) - - if not user: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" - ) - - return user["user_id"] - - -async def get_user_by_userid(request: Request, user_id: str): - users = request.app.db["users"] - - user = await users.find_one({"user_id": user_id}) - - if not user: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" - ) - - user = User(**user) return user -async def get_profile_metadata(request: Request, user): - users = request.app.db["users"] - request.app.db["roles"] +## 🔒 RBAC Utils ## - user = await users.find_one({"user_id": user["user_id"]}) - if not user: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="User does not exist" +async def rbac_check( + request: Request, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + user_uuid: str, + db_session: Session, +): + if action == "create": + if current_user.id == 0: # if user is anonymous + return True + else: + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, "create", "user_x", db_session + ) + + else: + await authorization_verify_if_user_is_anon(current_user.id) + + # if user is the same as the one being read + if current_user.user_uuid == user_uuid: + return True + + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, user_uuid, db_session ) - return {"user_object": PublicUser(**user), "roles": "random"} - -# Verification of the user's permissions on the roles - - -async def verify_user_rights_on_user( - request: Request, - current_user: PublicUser, - action: Literal["create", "read", "update", "delete"], - user_id: str, -): - users = request.app.db["users"] - user = UserInDB(**await users.find_one({"user_id": user_id})) - - if action == "create": - return True - - if action == "read": - await authorization_verify_if_user_is_anon(current_user.user_id) - - if current_user.user_id == user_id: - return True - - for org in current_user.orgs: - if org.org_id in [org.org_id for org in user.orgs]: - return True - - return False - - if action == "update": - await authorization_verify_if_user_is_anon(current_user.user_id) - - if current_user.user_id == user_id: - return True - - for org in current_user.orgs: - if org.org_id in [org.org_id for org in user.orgs]: - if org.org_role == "owner": - return True - - await authorization_verify_based_on_roles( - request, current_user.user_id, "update", user["roles"], user_id - ) - - return False - - if action == "delete": - await authorization_verify_if_user_is_anon(current_user.user_id) - - if current_user.user_id == user_id: - return True - - for org in current_user.orgs: - if org.org_id in [org.org_id for org in user.orgs]: - if org.org_role == "owner": - return True - - await authorization_verify_based_on_roles( - request, current_user.user_id, "update", user["roles"], user_id - ) +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/utils/upload_content.py b/apps/api/src/services/utils/upload_content.py index 0776fe94..33200231 100644 --- a/apps/api/src/services/utils/upload_content.py +++ b/apps/api/src/services/utils/upload_content.py @@ -6,7 +6,7 @@ from config.config import get_learnhouse_config async def upload_content( - directory: str, org_id: str, file_binary: bytes, file_and_format: str + directory: str, org_uuid: str, file_binary: bytes, file_and_format: str ): # Get Learnhouse Config learnhouse_config = get_learnhouse_config() @@ -16,12 +16,12 @@ async def upload_content( if content_delivery == "filesystem": # create folder for activity - if not os.path.exists(f"content/{org_id}/{directory}"): + if not os.path.exists(f"content/{org_uuid}/{directory}"): # create folder for activity - os.makedirs(f"content/{org_id}/{directory}") + os.makedirs(f"content/{org_uuid}/{directory}") # upload file to server with open( - f"content/{org_id}/{directory}/{file_and_format}", + f"content/{org_uuid}/{directory}/{file_and_format}", "wb", ) as f: f.write(file_binary) @@ -37,13 +37,13 @@ async def upload_content( ) # Create folder for activity - if not os.path.exists(f"content/{org_id}/{directory}"): + if not os.path.exists(f"content/{org_uuid}/{directory}"): # create folder for activity - os.makedirs(f"content/{org_id}/{directory}") + os.makedirs(f"content/{org_uuid}/{directory}") # Upload file to server with open( - f"content/{org_id}/{directory}/{file_and_format}", + f"content/{org_uuid}/{directory}/{file_and_format}", "wb", ) as f: f.write(file_binary) @@ -52,9 +52,9 @@ async def upload_content( print("Uploading to s3 using boto3...") try: s3.upload_file( - f"content/{org_id}/{directory}/{file_and_format}", + f"content/{org_uuid}/{directory}/{file_and_format}", "learnhouse-media", - f"content/{org_id}/{directory}/{file_and_format}", + f"content/{org_uuid}/{directory}/{file_and_format}", ) except ClientError as e: print(e) @@ -63,7 +63,7 @@ async def upload_content( try: s3.head_object( Bucket="learnhouse-media", - Key=f"content/{org_id}/{directory}/{file_and_format}", + Key=f"content/{org_uuid}/{directory}/{file_and_format}", ) print("File upload successful!") except Exception as e: diff --git a/apps/web/app/editor/course/[courseid]/activity/[activityid]/edit/loading.tsx b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/loading.tsx similarity index 100% rename from apps/web/app/editor/course/[courseid]/activity/[activityid]/edit/loading.tsx rename to apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/loading.tsx diff --git a/apps/web/app/editor/course/[courseid]/activity/[activityid]/edit/page.tsx b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx similarity index 66% rename from apps/web/app/editor/course/[courseid]/activity/[activityid]/edit/page.tsx rename to apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx index d89a99cf..55948525 100644 --- a/apps/web/app/editor/course/[courseid]/activity/[activityid]/edit/page.tsx +++ b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx @@ -1,11 +1,12 @@ import { default as React, } from "react"; -import AuthProvider from "@components/Security/AuthProvider"; +import AuthProvider from "@components/Security/AuthProviderDepreceated"; import EditorWrapper from "@components/Objects/Editor/EditorWrapper"; import { getCourseMetadataWithAuthHeader } from "@services/courses/courses"; import { cookies } from "next/headers"; import { Metadata } from "next"; import { getActivityWithAuthHeader } from "@services/courses/activities"; import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; +import { getOrganizationContextInfo, getOrganizationContextInfoWithId } from "@services/organizations/orgs"; type MetadataProps = { params: { orgslug: string, courseid: string, activityid: string }; @@ -21,26 +22,25 @@ export async function generateMetadata( const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null) return { - title: `Edit - ${course_meta.course.name} Activity`, - description: course_meta.course.mini_description, + title: `Edit - ${course_meta.name} Activity`, + description: course_meta.mini_description, }; } const EditActivity = async (params: any) => { const cookieStore = cookies(); const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore) - const activityid = params.params.activityid; + const activityuuid = params.params.activityuuid; const courseid = params.params.courseid; - const orgslug = params.params.orgslug; - const courseInfo = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null) - const activity = await getActivityWithAuthHeader(activityid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null) - + const activity = await getActivityWithAuthHeader(activityuuid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null) + const org = await getOrganizationContextInfoWithId(courseInfo.org_id, { revalidate: 1800, tags: ['organizations'] }); + console.log('courseInfo', courseInfo ) return (
- +
); diff --git a/apps/web/app/organizations/page.tsx b/apps/web/app/organizations/page.tsx index e2654448..009c0b19 100644 --- a/apps/web/app/organizations/page.tsx +++ b/apps/web/app/organizations/page.tsx @@ -5,7 +5,7 @@ import { deleteOrganizationFromBackend } from "@services/organizations/orgs"; import useSWR, { mutate } from "swr"; import { swrFetcher } from "@services/utils/ts/requests"; import { getAPIUrl, getUriWithOrg } from "@services/config/config"; -import AuthProvider from "@components/Security/AuthProvider"; +import AuthProvider from "@components/Security/AuthProviderDepreceated"; const Organizations = () => { const { data: organizations, error } = useSWR(`${getAPIUrl()}orgs/user/page/1/limit/10`, swrFetcher) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx index ad1e8569..95b61d7e 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collection/[collectionid]/page.tsx @@ -48,6 +48,7 @@ export async function generateMetadata( const CollectionPage = async (params: any) => { const cookieStore = cookies(); const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore) + const org = await getOrganizationContextInfo(params.params.orgslug, { revalidate: 1800, tags: ['organizations'] }); const orgslug = params.params.orgslug; const col = await getCollectionByIdWithAuthHeader(params.params.collectionid, access_token ? access_token : null, { revalidate: 0, tags: ['collections'] }); @@ -62,9 +63,9 @@ const CollectionPage = async (params: any) => {
{col.courses.map((course: any) => ( -
- -
+
+ +

{course.name}

diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx index e338f86e..85922e14 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx @@ -41,7 +41,7 @@ function NewCollection(params: any) { description: description, courses: selectedCourses, public: true, - org_id: org.org_id, + org_id: org.id, }; await createCollection(collection); await revalidateTags(["collections"], orgslug); @@ -69,26 +69,29 @@ function NewCollection(params: any) { ) : (
{courses.map((course: any) => ( -
- { - const courseId = e.target.value; - setSelectedCourses((prevSelectedCourses: string[]) => { - if (e.target.checked) { - return [...prevSelectedCourses, courseId]; - } else { - return prevSelectedCourses.filter((selectedCourse) => selectedCourse !== courseId); - } - }); - }} - className="mr-2 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - +
+ + { + if (e.target.checked) { + setSelectedCourses([...selectedCourses, course.id]); + } + else { + setSelectedCourses(selectedCourses.filter((course_uuid: any) => course_uuid !== course.course_uuid)); + } + } + } + className="mr-2" +/> + +
))} diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx index 864450fd..ee4890b7 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx @@ -8,7 +8,7 @@ import { Metadata } from "next"; import { cookies } from "next/headers"; import Link from "next/link"; import { getAccessTokenFromRefreshTokenCookie } from "@services/auth/auth"; -import CollectionThumbnail from "@components/Objects/Other/CollectionThumbnail"; +import CollectionThumbnail from "@components/Objects/Thumbnails/CollectionThumbnail"; import NewCollectionButton from "@components/StyledElements/Buttons/NewCollectionButton"; type MetadataProps = { @@ -49,14 +49,17 @@ const CollectionsPage = async (params: any) => { const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore) const orgslug = params.params.orgslug; const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); - const org_id = org.org_id; + const org_id = org.id; const collections = await getOrgCollectionsWithAuthHeader(org_id, access_token ? access_token : null, { revalidate: 0, tags: ['collections'] }); return (
- + @@ -64,7 +67,7 @@ const CollectionsPage = async (params: any) => {
{collections.map((collection: any) => ( -
+
))} @@ -81,7 +84,10 @@ const CollectionsPage = async (params: any) => {

No collections yet

Create a collection to group courses together

- + diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx deleted file mode 100644 index 596dcf6f..00000000 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/activity/[activityid]/activity.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client"; -import Link from "next/link"; -import { getUriWithOrg } from "@services/config/config"; -import Canva from "@components/Objects/Activities/DynamicCanva/DynamicCanva"; -import VideoActivity from "@components/Objects/Activities/Video/Video"; -import { Check } from "lucide-react"; -import { markActivityAsComplete } from "@services/courses/activity"; -import DocumentPdfActivity from "@components/Objects/Activities/DocumentPdf/DocumentPdf"; -import ActivityIndicators from "@components/Pages/Courses/ActivityIndicators"; -import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; -import { useRouter } from "next/navigation"; -import AuthenticatedClientElement from "@components/Security/AuthenticatedClientElement"; -import { getCourseThumbnailMediaDirectory } from "@services/media/media"; - -interface ActivityClientProps { - activityid: string; - courseid: string; - orgslug: string; - activity: any; - course: any; -} - - -function ActivityClient(props: ActivityClientProps) { - const activityid = props.activityid; - const courseid = props.courseid; - const orgslug = props.orgslug; - const activity = props.activity; - const course = props.course; - - function getChapterName(chapterId: string) { - let chapterName = ""; - course.chapters.forEach((chapter: any) => { - if (chapter.id === chapterId) { - chapterName = chapter.name; - } - }); - return chapterName; - } - - - - return ( - <> - -
-
-
- - - -
-
-

Course

-

{course.course.name}

-
-
- - -
-
-

Chapter : {getChapterName(activity.coursechapter_id)}

-

{activity.name}

-
-
- - - - -
-
- - {activity ? ( -
-
- {activity.type == "dynamic" && } - {/* todo : use apis & streams instead of this */} - {activity.type == "video" && } - {activity.type == "documentpdf" && } -
-
- ) : (
)} - {
} -
-
- - ); -} - - - -export function MarkStatus(props: { activityid: string, course: any, orgslug: string, courseid: string }) { - const router = useRouter(); - - - async function markActivityAsCompleteFront() { - const trail = await markActivityAsComplete(props.orgslug, props.courseid, props.activityid); - router.refresh(); - - // refresh page (FIX for Next.js BUG) - //window.location.reload(); - - } - - return ( - <>{props.course.trail.activities_marked_complete && - props.course.trail.activities_marked_complete.includes("activity_" + props.activityid) && - props.course.trail.status == "ongoing" ? ( -
- - - {" "} - Already completed -
- ) : ( -
- {" "} - - - {" "} - Mark as complete -
- )} - ) -} - - - -export default ActivityClient; diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/edit.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/edit.tsx deleted file mode 100644 index ed292f48..00000000 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/edit.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; -import React, { FC, use, useEffect, useReducer } from 'react' -import { revalidateTags, swrFetcher } from "@services/utils/ts/requests"; -import { getAPIUrl, getUriWithOrg } from '@services/config/config'; -import useSWR, { mutate } from 'swr'; -import { getCourseThumbnailMediaDirectory } from '@services/media/media'; -import Link from 'next/link'; -import CourseEdition from '../subpages/CourseEdition'; -import CourseContentEdition from '../subpages/CourseContentEdition'; -import ErrorUI from '@components/StyledElements/Error/Error'; -import { updateChaptersMetadata } from '@services/courses/chapters'; -import { Check, SaveAllIcon, Timer } from 'lucide-react'; -import Loading from '../../loading'; -import { updateCourse } from '@services/courses/courses'; -import { useRouter } from 'next/navigation'; - -function CourseEditClient({ courseid, subpage, params }: { courseid: string, subpage: string, params: any }) { - const { data: chapters_meta, error: chapters_meta_error, isLoading: chapters_meta_isloading } = useSWR(`${getAPIUrl()}chapters/meta/course_${courseid}`, swrFetcher); - const { data: course, error: course_error, isLoading: course_isloading } = useSWR(`${getAPIUrl()}courses/course_${courseid}`, swrFetcher); - const [courseChaptersMetadata, dispatchCourseChaptersMetadata] = useReducer(courseChaptersReducer, {}); - const [courseState, dispatchCourseMetadata] = useReducer(courseReducer, {}); - const [savedContent, dispatchSavedContent] = useReducer(savedContentReducer, true); - const router = useRouter(); - - - - function courseChaptersReducer(state: any, action: any) { - switch (action.type) { - case 'updated_chapter': - // action will contain the entire state, just update the entire state - return action.payload; - default: - throw new Error(); - } - } - - function courseReducer(state: any, action: any) { - switch (action.type) { - case 'updated_course': - // action will contain the entire state, just update the entire state - return action.payload; - default: - throw new Error(); - } - } - - function savedContentReducer(state: any, action: any) { - switch (action.type) { - case 'saved_content': - return true; - case 'unsaved_content': - return false; - default: - throw new Error(); - } - } - - async function saveCourse() { - if (subpage.toString() === 'content') { - await updateChaptersMetadata(courseid, courseChaptersMetadata) - dispatchSavedContent({ type: 'saved_content' }) - await mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`) - await revalidateTags(['courses'], params.params.orgslug) - router.refresh() - } - else if (subpage.toString() === 'general') { - await updateCourse(courseid, courseState) - dispatchSavedContent({ type: 'saved_content' }) - await mutate(`${getAPIUrl()}courses/course_${courseid}`) - await revalidateTags(['courses'], params.params.orgslug) - router.refresh() - } - } - - useEffect(() => { - if (chapters_meta) { - dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: chapters_meta }) - dispatchSavedContent({ type: 'saved_content' }) - } - if (course) { - dispatchCourseMetadata({ type: 'updated_course', payload: course }) - dispatchSavedContent({ type: 'saved_content' }) - } - }, [chapters_meta, course]) - - return ( - <> -
-
- {course_isloading &&
Loading...
} - {course && <> -
-
- - - -
-
-
Edit Course
-
{course.name}
-
-
-
- {savedContent ? <> :
- -
- Unsaved changes -
- -
} -
- - {savedContent ? : } - {savedContent ?
Saved
:
Save
} -
-
-
- } -
- -
General
- - -
Content
- -
-
-
- - - - ) -} - -const CoursePageViewer = ({ subpage, courseid, orgslug, dispatchCourseMetadata, dispatchCourseChaptersMetadata, courseChaptersMetadata, dispatchSavedContent, courseState }: { subpage: string, courseid: string, orgslug: string, dispatchCourseChaptersMetadata: React.Dispatch, dispatchCourseMetadata: React.Dispatch, dispatchSavedContent: React.Dispatch, courseChaptersMetadata: any, courseState: any }) => { - if (subpage.toString() === 'general' && Object.keys(courseState).length !== 0) { - return - } - else if (subpage.toString() === 'content' && Object.keys(courseChaptersMetadata).length !== 0) { - return - } - else if (subpage.toString() === 'content' || subpage.toString() === 'general') { - return - } - else { - return - } - -} - -export default CourseEditClient \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/page.tsx deleted file mode 100644 index 28e4a8d4..00000000 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/[[...subpage]]/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { getOrganizationContextInfo } from "@services/organizations/orgs"; -import CourseEditClient from "./edit"; -import { getCourseMetadataWithAuthHeader } from "@services/courses/courses"; -import { cookies } from "next/headers"; -import { Metadata } from 'next'; -import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshTokenServer } from "@services/auth/auth"; - -type MetadataProps = { - params: { orgslug: string, courseid: string }; - searchParams: { [key: string]: string | string[] | undefined }; -}; - -export async function generateMetadata( - { params }: MetadataProps, -): Promise { - const cookieStore = cookies(); - const access_token = await getAccessTokenFromRefreshTokenCookie(cookieStore) - - - // Get Org context information - const org = await getOrganizationContextInfo(params.orgslug, { revalidate: 1800, tags: ['organizations'] }); - const course_meta = await getCourseMetadataWithAuthHeader(params.courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null) - - return { - title: `Edit Course - ` + course_meta.course.name, - description: course_meta.course.mini_description, - }; -} - - -function CourseEdit(params: any) { - let subpage = params.params.subpage ? params.params.subpage : 'general'; - return ( - <> - - - ); -} - - -export default CourseEdit; diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseContentEdition.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseContentEdition.tsx deleted file mode 100644 index b3634d5f..00000000 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseContentEdition.tsx +++ /dev/null @@ -1,320 +0,0 @@ -"use client"; -import React from "react"; -import { useState, useEffect } from "react"; -import { DragDropContext, Droppable } from "react-beautiful-dnd"; -import Chapter from "@components/Pages/CourseEdit/Draggables/Chapter"; -import { createChapter, deleteChapter, getCourseChaptersMetadata, updateChaptersMetadata } from "@services/courses/chapters"; -import { useRouter } from "next/navigation"; -import NewChapterModal from "@components/Objects/Modals/Chapters/NewChapter"; -import NewActivityModal from "@components/Objects/Modals/Activities/Create/NewActivity"; -import { createActivity, createFileActivity, createExternalVideoActivity } from "@services/courses/activities"; -import { getOrganizationContextInfo, getOrganizationContextInfoWithoutCredentials } from "@services/organizations/orgs"; -import Modal from "@components/StyledElements/Modal/Modal"; -import { denyAccessToUser } from "@services/utils/react/middlewares/views"; -import { Folders, Hexagon, SaveIcon } from "lucide-react"; -import GeneralWrapperStyled from "@components/StyledElements/Wrappers/GeneralWrapper"; -import { revalidateTags, swrFetcher } from "@services/utils/ts/requests"; -import { mutate } from "swr"; -import { getAPIUrl } from "@services/config/config"; - -function CourseContentEdition(props: any) { - const router = useRouter(); - // Initial Course State - const data = props.data; - - // New Chapter Modal State - const [newChapterModal, setNewChapterModal] = useState(false) as any; - // New Activity Modal State - const [newActivityModal, setNewActivityModal] = useState(false) as any; - const [newActivityModalData, setNewActivityModalData] = useState("") as any; - - // Check window availability - const [winReady, setwinReady] = useState(false); - const courseid = props.courseid; - const orgslug = props.orgslug; - - - - useEffect(() => { - setwinReady(true); - }, [courseid, orgslug]); - - // get a list of chapters order by chapter order - const getChapters = () => { - const chapterOrder = data.chapterOrder ? data.chapterOrder : []; - return chapterOrder.map((chapterId: any) => { - const chapter = data.chapters[chapterId]; - let activities = []; - if (data.activities) { - activities = chapter.activityIds.map((activityId: any) => data.activities[activityId]) - ? chapter.activityIds.map((activityId: any) => data.activities[activityId]) - : []; - } - return { - list: { - chapter: chapter, - activities: activities, - }, - }; - }); - }; - - // Submit new chapter - const submitChapter = async (chapter: any) => { - await createChapter(chapter, courseid); - mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`); - // await getCourseChapters(); - await revalidateTags(['courses'], orgslug); - router.refresh(); - setNewChapterModal(false); - }; - - // Submit new activity - const submitActivity = async (activity: any) => { - let org = await getOrganizationContextInfoWithoutCredentials(orgslug, { revalidate: 1800 }); - await updateChaptersMetadata(courseid, data); - await createActivity(activity, activity.chapterId, org.org_id); - mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`); - // await getCourseChapters(); - setNewActivityModal(false); - await revalidateTags(['courses'], orgslug); - router.refresh(); - }; - - - - // Submit File Upload - const submitFileActivity = async (file: any, type: any, activity: any, chapterId: string) => { - await updateChaptersMetadata(courseid, data); - await createFileActivity(file, type, activity, chapterId); - mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`); - // await getCourseChapters(); - setNewActivityModal(false); - await revalidateTags(['courses'], orgslug); - router.refresh(); - }; - - // Submit YouTube Video Upload - const submitExternalVideo = async (external_video_data: any, activity: any, chapterId: string) => { - await updateChaptersMetadata(courseid, data); - await createExternalVideoActivity(external_video_data, activity, chapterId); - mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`); - // await getCourseChapters(); - setNewActivityModal(false); - await revalidateTags(['courses'], orgslug); - router.refresh(); - }; - - const deleteChapterUI = async (chapterId: any) => { - - await deleteChapter(chapterId); - mutate(`${getAPIUrl()}chapters/meta/course_${courseid}`); - // await getCourseChapters(); - await revalidateTags(['courses'], orgslug); - router.refresh(); - }; - - const updateChapters = () => { - updateChaptersMetadata(courseid, data); - revalidateTags(['courses'], orgslug); - router.refresh(); - }; - - /* - Modals - */ - - const openNewActivityModal = async (chapterId: any) => { - setNewActivityModal(true); - setNewActivityModalData(chapterId); - }; - - // Close new chapter modal - const closeNewChapterModal = () => { - setNewChapterModal(false); - }; - - const closeNewActivityModal = () => { - setNewActivityModal(false); - }; - - /* - Drag and drop functions - - */ - const onDragEnd = async (result: any) => { - const { destination, source, draggableId, type } = result; - - - // check if the activity is dropped outside the droppable area - if (!destination) { - return; - } - - // check if the activity is dropped in the same place - if (destination.droppableId === source.droppableId && destination.index === source.index) { - return; - } - //////////////////////////// CHAPTERS //////////////////////////// - if (type === "chapter") { - const newChapterOrder = Array.from(data.chapterOrder); - newChapterOrder.splice(source.index, 1); - newChapterOrder.splice(destination.index, 0, draggableId); - - const newState = { - ...data, - chapterOrder: newChapterOrder, - }; - - props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState }) - props.dispatchSavedContent({ type: 'unsaved_content' }) - //setData(newState); - return; - } - - //////////////////////// ACTIVITIES IN SAME CHAPTERS //////////////////////////// - // check if the activity is dropped in the same chapter - const start = data.chapters[source.droppableId]; - const finish = data.chapters[destination.droppableId]; - - // check if the activity is dropped in the same chapter - if (start === finish) { - // create new arrays for chapters and activities - const chapter = data.chapters[source.droppableId]; - const newActivityIds = Array.from(chapter.activityIds); - - // remove the activity from the old position - newActivityIds.splice(source.index, 1); - - // add the activity to the new position - newActivityIds.splice(destination.index, 0, draggableId); - - const newChapter = { - ...chapter, - activityIds: newActivityIds, - }; - - const newState = { - ...data, - chapters: { - ...data.chapters, - [newChapter.id]: newChapter, - }, - }; - props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState }) - props.dispatchSavedContent({ type: 'unsaved_content' }) - //setData(newState); - return; - } - - //////////////////////// ACTIVITIES IN DIFF CHAPTERS //////////////////////////// - // check if the activity is dropped in a different chapter - if (start !== finish) { - // create new arrays for chapters and activities - const startChapterActivityIds = Array.from(start.activityIds); - - // remove the activity from the old position - startChapterActivityIds.splice(source.index, 1); - const newStart = { - ...start, - activityIds: startChapterActivityIds, - }; - - // add the activity to the new position within the chapter - const finishChapterActivityIds = Array.from(finish.activityIds); - finishChapterActivityIds.splice(destination.index, 0, draggableId); - const newFinish = { - ...finish, - activityIds: finishChapterActivityIds, - }; - - const newState = { - ...data, - chapters: { - ...data.chapters, - [newStart.id]: newStart, - [newFinish.id]: newFinish, - }, - }; - - props.dispatchCourseChaptersMetadata({ type: 'updated_chapter', payload: newState }) - props.dispatchSavedContent({ type: 'unsaved_content' }) - //setData(newState); - return; - } - }; - - return ( - <> -
- - } - dialogTitle="Create Activity" - dialogDescription="Choose between types of activities to add to the course" - - /> - {winReady && ( -
- - - {(provided) => ( - <> -
- {getChapters().map((info: any, index: any) => ( - <> - - - ))} - {provided.placeholder} -
- - )} -
-
- } - dialogTitle="Create chapter" - dialogDescription="Add a new chapter to the course" - dialogTrigger={ -
- -
Add chapter +
-
- } - /> -
- )} -
-
- - ); -} - - -export default CourseContentEdition; \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseEdition.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseEdition.tsx deleted file mode 100644 index 23b77f85..00000000 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseid]/edit/subpages/CourseEdition.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; -import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input, Textarea } from '@components/StyledElements/Form/Form' -import * as Form from '@radix-ui/react-form'; -import { useFormik } from 'formik'; -import { AlertTriangle } from "lucide-react"; -import React from "react"; - -const validate = (values: any) => { - const errors: any = {}; - - if (!values.name) { - errors.name = 'Required'; - } - - if (values.name.length > 100) { - errors.name = 'Must be 80 characters or less'; - } - - if (!values.mini_description) { - errors.mini_description = 'Required'; - } - - if (values.mini_description.length > 200) { - errors.mini_description = 'Must be 200 characters or less'; - } - - if (!values.description) { - errors.description = 'Required'; - - } - - if (values.description.length > 1000) { - errors.description = 'Must be 1000 characters or less'; - } - - - if (!values.learnings) { - errors.learnings = 'Required'; - } - - return errors; -}; - -function CourseEdition(props: any) { - const [error, setError] = React.useState(''); - const formik = useFormik({ - initialValues: { - name: String(props.data.name), - mini_description: String(props.data.mini_description), - description: String(props.data.description), - learnings: String(props.data.learnings), - }, - validate, - onSubmit: async values => { - }, - }); - - - React.useEffect(() => { - // This code will run whenever form values are updated - if (formik.values !== formik.initialValues) { - props.dispatchSavedContent({ type: 'unsaved_content' }); - const updatedCourse = { - ...props.data, - name: formik.values.name, - mini_description: formik.values.mini_description, - description: formik.values.description, - learnings: formik.values.learnings.split(", "), - }; - props.dispatchCourseMetadata({ type: 'updated_course', payload: updatedCourse }); - } - }, [formik.values, formik.initialValues]); - - - return ( -
-
- {error && ( -
- -
{error}
-
- )} - - - - - - - - - - - - - - - - -