diff --git a/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py new file mode 100644 index 00000000..62370b4e --- /dev/null +++ b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py @@ -0,0 +1,41 @@ +"""Organizations new model + +Revision ID: 87a621284ae4 +Revises: 0314ec7791e1 +Create Date: 2024-12-17 22:51:50.998443 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 + + +# revision identifiers, used by Alembic. +revision: str = '87a621284ae4' +down_revision: Union[str, None] = '0314ec7791e1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('organization', sa.Column('about', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('organization', sa.Column('socials', sa.JSON(), nullable=True)) + op.add_column('organization', sa.Column('links', sa.JSON(), nullable=True)) + op.add_column('organization', sa.Column('previews', sa.JSON(), nullable=True)) + op.add_column('organization', sa.Column('explore', sa.Boolean(), nullable=True)) + op.add_column('organization', sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('organization', 'label') + op.drop_column('organization', 'explore') + op.drop_column('organization', 'previews') + op.drop_column('organization', 'links') + op.drop_column('organization', 'socials') + op.drop_column('organization', 'about') + # ### end Alembic commands ### diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py index f5702103..81dbfea8 100644 --- a/apps/api/src/db/organizations.py +++ b/apps/api/src/db/organizations.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import BaseModel -from sqlmodel import Field, SQLModel +from sqlmodel import Field, SQLModel, JSON, Column from src.db.roles import RoleRead from src.db.organization_config import OrganizationConfig @@ -9,10 +9,16 @@ from src.db.organization_config import OrganizationConfig class OrganizationBase(SQLModel): name: str description: Optional[str] - slug: str - email: str + about: Optional[str] + socials: Optional[dict] = Field(default={}, sa_column=Column(JSON)) + links: Optional[dict] = Field(default={}, sa_column=Column(JSON)) logo_image: Optional[str] thumbnail_image: Optional[str] + previews: Optional[dict] = Field(default={}, sa_column=Column(JSON)) + explore: Optional[bool] = Field(default=False) + label: Optional[str] + slug: str + email: str class Organization(OrganizationBase, table=True): @@ -26,9 +32,19 @@ class OrganizationWithConfig(BaseModel): config: OrganizationConfig -class OrganizationUpdate(OrganizationBase): - pass - +class OrganizationUpdate(SQLModel): + name: Optional[str] = None + description: Optional[str] = None + about: Optional[str] = None + socials: Optional[dict] = None + links: Optional[dict] = None + logo_image: Optional[str] = None + thumbnail_image: Optional[str] = None + previews: Optional[dict] = None + label: Optional[str] = None + slug: Optional[str] = None + email: Optional[str] = None + explore: Optional[bool] = None class OrganizationCreate(OrganizationBase): pass diff --git a/apps/api/src/routers/ee/cloud_internal.py b/apps/api/src/routers/ee/cloud_internal.py index b4224ddf..8134fd31 100644 --- a/apps/api/src/routers/ee/cloud_internal.py +++ b/apps/api/src/routers/ee/cloud_internal.py @@ -1,8 +1,10 @@ import os +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session from src.core.events.database import get_db_session from src.db.organization_config import OrganizationConfigBase +from src.services.explore.explore import get_course_for_explore, get_courses_for_an_org_explore, get_org_for_explore, get_orgs_for_explore, search_orgs_for_explore from src.services.orgs.orgs import update_org_with_config_no_auth router = APIRouter() @@ -14,6 +16,49 @@ def check_internal_cloud_key(request: Request): ): raise HTTPException(status_code=403, detail="Unauthorized") +@router.get("/explore/orgs") +async def api_get_orgs_for_explore( + request: Request, + page: int = 1, + limit: int = 10, + label: str = "", + salt: str = "", + db_session: Session = Depends(get_db_session), +): + return await get_orgs_for_explore(request, db_session, page, limit, label, salt) + +@router.get("/explore/orgs/search") +async def api_search_orgs_for_explore( + request: Request, + search_query: str, + label: Optional[str] = None, + db_session: Session = Depends(get_db_session), +): + return await search_orgs_for_explore(request, db_session, search_query, label) + +@router.get("/explore/orgs/{org_uuid}/courses") +async def api_get_courses_for_explore( + request: Request, + org_uuid: str, + db_session: Session = Depends(get_db_session), +): + return await get_courses_for_an_org_explore(request, db_session, org_uuid) + +@router.get("/explore/courses/{course_id}") +async def api_get_course_for_explore( + request: Request, + course_id: str, + db_session: Session = Depends(get_db_session), +): + return await get_course_for_explore(request, course_id, db_session) + +@router.get("/explore/orgs/{org_slug}") +async def api_get_org_for_explore( + request: Request, + org_slug: str, + db_session: Session = Depends(get_db_session), +): + return await get_org_for_explore(request, org_slug, db_session) @router.put("/update_org_config") async def update_org_Config( diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 5c171f62..e6025829 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -37,6 +37,7 @@ from src.services.orgs.orgs import ( get_orgs_by_user_admin, update_org, update_org_logo, + update_org_preview, update_org_signup_mechanism, update_org_thumbnail, ) @@ -334,6 +335,25 @@ async def api_update_org_thumbnail( db_session=db_session, ) +@router.put("/{org_id}/preview") +async def api_update_org_preview( + request: Request, + org_id: str, + preview_file: UploadFile, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Update org thumbnail + """ + return await update_org_preview( + request=request, + preview_file=preview_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( diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index d54faedf..774e2393 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -47,15 +47,23 @@ async def get_collection( # get courses in collection statement_all = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .where(CollectionCourse.org_id == collection.org_id) - .distinct(Course.id) + .join(CollectionCourse) + .where( + CollectionCourse.collection_id == collection.id, + CollectionCourse.org_id == collection.org_id + ) + .distinct() ) statement_public = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .where(CollectionCourse.org_id == collection.org_id, Course.public == True) + .join(CollectionCourse) + .where( + CollectionCourse.collection_id == collection.id, + CollectionCourse.org_id == collection.org_id, + Course.public == True + ) + .distinct() ) if current_user.user_uuid == "user_anonymous": @@ -63,7 +71,7 @@ async def get_collection( else: statement = statement_all - courses = db_session.exec(statement).all() + courses = list(db_session.exec(statement).all()) collection = CollectionRead(**collection.model_dump(), courses=courses) @@ -110,10 +118,11 @@ async def create_collection( # Get courses once again statement = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .distinct(Course.id) + .join(CollectionCourse) + .where(CollectionCourse.collection_id == collection.id) + .distinct() ) - courses = db_session.exec(statement).all() + courses = list(db_session.exec(statement).all()) collection = CollectionRead(**collection.model_dump(), courses=courses) @@ -183,12 +192,11 @@ async def update_collection( # Get courses once again statement = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .where(Course.org_id == collection.org_id) - .distinct(Course.id) + .join(CollectionCourse) + .where(CollectionCourse.collection_id == collection.id) + .distinct() ) - - courses = db_session.exec(statement).all() + courses = list(db_session.exec(statement).all()) collection = CollectionRead(**collection.model_dump(), courses=courses) @@ -255,14 +263,22 @@ async def get_collections( for collection in collections: statement_all = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .where(CollectionCourse.org_id == collection.org_id) - .distinct(Course.id) + .join(CollectionCourse) + .where( + CollectionCourse.collection_id == collection.id, + CollectionCourse.org_id == collection.org_id + ) + .distinct() ) statement_public = ( select(Course) - .join(CollectionCourse, Course.id == CollectionCourse.course_id) - .where(CollectionCourse.org_id == org_id, Course.public == True) + .join(CollectionCourse) + .where( + CollectionCourse.collection_id == collection.id, + CollectionCourse.org_id == org_id, + Course.public == True + ) + .distinct() ) if current_user.id == 0: statement = statement_public diff --git a/apps/api/src/services/explore/explore.py b/apps/api/src/services/explore/explore.py new file mode 100644 index 00000000..9cc866e5 --- /dev/null +++ b/apps/api/src/services/explore/explore.py @@ -0,0 +1,165 @@ +from typing import Optional +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from sqlalchemy import text + +from src.db.courses.courses import Course, CourseRead +from src.db.organizations import Organization, OrganizationRead + + +def _get_sort_expression(salt: str): + """Helper function to create consistent sort expression""" + if not salt: + return Organization.name + + # Create a deterministic ordering using md5(salt + id) + return text( + f"md5('{salt}' || id)" + ) + +async def get_orgs_for_explore( + request: Request, + db_session: Session, + page: int = 1, + limit: int = 10, + label: str = "", + salt: str = "", +) -> list[OrganizationRead]: + + statement = ( + select(Organization) + .where( + Organization.explore == True, + ) + ) + + # Add label filter if provided + if label: + statement = statement.where(Organization.label == label) #type: ignore + + # Add deterministic ordering based on salt + statement = statement.order_by(_get_sort_expression(salt)) + + # Add pagination + statement = ( + statement + .offset((page - 1) * limit) + .limit(limit) + ) + + result = db_session.exec(statement) + orgs = result.all() + + return [OrganizationRead.model_validate(org) for org in orgs] + + + +async def get_courses_for_an_org_explore( + request: Request, + db_session: Session, + org_uuid: str, +) -> list[CourseRead]: + statement = select(Organization).where(Organization.org_uuid == org_uuid) + result = db_session.exec(statement) + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + statement = select(Course).where(Course.org_id == org.id, Course.public == True) + result = db_session.exec(statement) + courses = result.all() + + courses_list = [] + + for course in courses: + courses_list.append(course) + + return courses_list + +async def get_course_for_explore( + request: Request, + course_id: str, + db_session: Session, +) -> CourseRead: + statement = select(Course).where(Course.id == course_id) + result = db_session.exec(statement) + + course = result.first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + return CourseRead.model_validate(course) + +async def search_orgs_for_explore( + request: Request, + db_session: Session, + search_query: str, + label: Optional[str] = None, + page: int = 1, + limit: int = 10, + salt: str = "", +) -> list[OrganizationRead]: + # Create a combined search vector + search_terms = search_query.split() + search_conditions = [] + + for term in search_terms: + term_pattern = f"%{term}%" + search_conditions.append( + (Organization.name.ilike(term_pattern)) | #type: ignore + (Organization.about.ilike(term_pattern)) | #type: ignore + (Organization.description.ilike(term_pattern)) | #type: ignore + (Organization.label.ilike(term_pattern)) #type: ignore + ) + + statement = ( + select(Organization) + .where(Organization.explore == True) + ) + + if label and label != "all": + statement = statement.where(Organization.label == label) #type: ignore + + if search_conditions: + statement = statement.where(*search_conditions) + + # Add deterministic ordering based on salt + statement = statement.order_by(_get_sort_expression(salt)) + + # Add pagination + statement = ( + statement + .offset((page - 1) * limit) + .limit(limit) + ) + + result = db_session.exec(statement) + orgs = result.all() + + return [OrganizationRead.model_validate(org) for org in orgs] + +async def get_org_for_explore( + request: Request, + org_slug: str, + db_session: Session, + ) -> OrganizationRead: + statement = select(Organization).where(Organization.slug == org_slug) + result = db_session.exec(statement) + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + return OrganizationRead.model_validate(org) + \ No newline at end of file diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 13c084ed..447e26b1 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -36,7 +36,7 @@ from src.db.organizations import ( ) from fastapi import HTTPException, UploadFile, status, Request -from src.services.orgs.uploads import upload_org_logo, upload_org_thumbnail +from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail async def get_organization( @@ -174,7 +174,7 @@ async def create_org( storage=StorageOrgConfig(enabled=True, limit=0), ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"), assignments=AssignmentOrgConfig(enabled=True, limit=0), - payments=PaymentOrgConfig(enabled=True, stripe_key=""), + payments=PaymentOrgConfig(enabled=True), discussions=DiscussionOrgConfig(enabled=True, limit=0), analytics=AnalyticsOrgConfig(enabled=True, limit=0), collaboration=CollaborationOrgConfig(enabled=True, limit=0), @@ -458,6 +458,31 @@ async def update_org_thumbnail( return {"detail": "Thumbnail updated"} +async def update_org_preview( + request: Request, + preview_file: UploadFile, + org_id: str, + 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 not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Upload logo + name_in_disk = await upload_org_preview(preview_file, org.org_uuid) + + return {"name_in_disk": name_in_disk} async def delete_org( request: Request, @@ -675,6 +700,19 @@ async def get_org_join_mechanism( return signup_mechanism +async def upload_org_preview_service( + preview_file: UploadFile, + org_uuid: str, +) -> dict: + # No need for request or current_user since we're not doing RBAC checks for previews + + # Upload preview + name_in_disk = await upload_org_preview(preview_file, org_uuid) + + return { + "detail": "Preview uploaded successfully", + "filename": name_in_disk + } ## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/orgs/uploads.py b/apps/api/src/services/orgs/uploads.py index 813b625d..7c393d53 100644 --- a/apps/api/src/services/orgs/uploads.py +++ b/apps/api/src/services/orgs/uploads.py @@ -31,3 +31,18 @@ async def upload_org_thumbnail(thumbnail_file, org_uuid): ) return name_in_disk + + +async def upload_org_preview(file, org_uuid: str) -> str: + contents = file.file.read() + name_in_disk = f"{uuid4()}.{file.filename.split('.')[-1]}" + + await upload_content( + "previews", + "orgs", + org_uuid, + contents, + name_in_disk, + ) + + return name_in_disk \ No newline at end of file diff --git a/apps/api/src/services/users/emails.py b/apps/api/src/services/users/emails.py index 6e20e704..a2e383ff 100644 --- a/apps/api/src/services/users/emails.py +++ b/apps/api/src/services/users/emails.py @@ -39,7 +39,9 @@ def send_password_reset_email(

Hello {user.username}

-

Click here to reset your password.

+

You have requested to reset your password.

+

Here is your reset code: {generated_reset_code}

+

Click here to reset your password.

""", diff --git a/apps/web/app/auth/reset/reset.tsx b/apps/web/app/auth/reset/reset.tsx index c9d16bb9..7451d140 100644 --- a/apps/web/app/auth/reset/reset.tsx +++ b/apps/web/app/auth/reset/reset.tsx @@ -11,7 +11,7 @@ import * as Form from '@radix-ui/react-form' import { getOrgLogoMediaDirectory } from '@services/media/media' import { AlertTriangle, Info } from 'lucide-react' import Link from 'next/link' -import { getUriWithOrg } from '@services/config/config' +import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config' import { useOrg } from '@components/Contexts/OrgContext' import { useRouter, useSearchParams } from 'next/navigation' import { useFormik } from 'formik' @@ -139,9 +139,14 @@ function ResetPasswordClient() { )} {message && ( -
- -
{message}
+
+
+ +
{message}
+
+ + Please login again with your new password +
)} 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 be8cbd01..621ce2fa 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx @@ -7,17 +7,22 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { revalidateTags, swrFetcher } from '@services/utils/ts/requests' import { useOrg } from '@components/Contexts/OrgContext' import { useLHSession } from '@components/Contexts/LHSessionContext' +import { Loader2, Image as ImageIcon } from 'lucide-react' +import { toast } from 'react-hot-toast' +import Image from 'next/image' +import { getCourseThumbnailMediaDirectory } from '@services/media/media' function NewCollection(params: any) { const org = useOrg() as any - const session = useLHSession() as any; - const access_token = session?.data?.tokens?.access_token; + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token const orgslug = params.params.orgslug const [name, setName] = React.useState('') const [description, setDescription] = React.useState('') const [selectedCourses, setSelectedCourses] = React.useState([]) as any + const [isSubmitting, setIsSubmitting] = useState(false) const router = useRouter() - const { data: courses, error: error } = useSWR( + const { data: courses, error: error, isLoading } = useSWR( `${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`, (url) => swrFetcher(url, access_token) ) @@ -32,111 +37,189 @@ function NewCollection(params: any) { } const handleDescriptionChange = ( - event: React.ChangeEvent + event: React.ChangeEvent ) => { setDescription(event.target.value) } const handleSubmit = async (e: any) => { e.preventDefault() - - const collection = { - name: name, - description: description, - courses: selectedCourses, - public: isPublic, - org_id: org.id, + + if (!name.trim()) { + toast.error('Please enter a collection name') + return } - await createCollection(collection, session.data?.tokens?.access_token) - await revalidateTags(['collections'], org.slug) - // reload the page - router.refresh() - // wait for 2s before reloading the page - setTimeout(() => { + if (!description.trim()) { + toast.error('Please enter a description') + return + } + + if (selectedCourses.length === 0) { + toast.error('Please select at least one course') + return + } + + setIsSubmitting(true) + try { + const collection = { + name: name.trim(), + description: description.trim(), + courses: selectedCourses, + public: isPublic, + org_id: org.id, + } + await createCollection(collection, session.data?.tokens?.access_token) + await revalidateTags(['collections'], org.slug) + toast.success('Collection created successfully!') router.push(getUriWithOrg(orgslug, '/collections')) - }, 1000) + } catch (error) { + toast.error('Failed to create collection. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + if (error) { + return ( +
+
Failed to load courses. Please try again later.
+
+ ) } return ( - <> -
-
Add new
+
+
+
+

Create New Collection

+

+ Group your courses together in a collection to make them easier to find and manage. +

+
- +
+
+ - - - {!courses ? ( -

Loading...

- ) : ( -
-

Courses

- {courses.map((course: any) => ( -
+ Visibility + { - if (e.target.checked) { - setSelectedCourses([...selectedCourses, course.id]) - } else { - setSelectedCourses( - selectedCourses.filter( - (course_uuid: any) => - course_uuid !== course.course_uuid - ) - ) - } - }} - className="text-blue-500 rounded focus:ring-2 focus:ring-blue-500" - /> + + + + - -
- ))} +