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/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/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/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index 6054523b..44a886d5 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -1,17 +1,52 @@ 'use client' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { getUriWithOrg } from '@services/config/config' -import { Info } from 'lucide-react' +import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon } from 'lucide-react' import Link from 'next/link' import React, { useEffect } from 'react' import { motion } from 'framer-motion' import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral' +import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages' +import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials' export type OrgParams = { subpage: string orgslug: string } +interface TabItem { + id: string + label: string + icon: LucideIcon +} + +const SETTING_TABS: TabItem[] = [ + { id: 'general', label: 'General', icon: TextIcon }, + { id: 'previews', label: 'Images & Previews', icon: ImageIcon }, + { id: 'socials', label: 'Socials', icon: Share2Icon }, +] + +function TabLink({ tab, isActive, orgslug }: { + tab: TabItem, + isActive: boolean, + orgslug: string +}) { + return ( + +