diff --git a/cli.py b/cli.py index 1c75d72..3bf64bc 100644 --- a/cli.py +++ b/cli.py @@ -30,6 +30,15 @@ def create_management_user(): ) +@app.command() +def create_initial_organizer_types(): + from models import factory_session + from repository.organizer_type import insert_initial_organizer_types + + with factory_session() as db: + insert_initial_organizer_types(db=db) + + @app.command() def initial_data(): from seeders.initial_seeders import initial_seeders diff --git a/core/helper.py b/core/helper.py index 3fdfd04..c9d757b 100644 --- a/core/helper.py +++ b/core/helper.py @@ -1,6 +1,7 @@ from datetime import datetime from fastapi import UploadFile from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError def save_file_and_get_url(url: Optional[UploadFile]) -> Optional[str]: @@ -8,3 +9,19 @@ def save_file_and_get_url(url: Optional[UploadFile]) -> Optional[str]: # Simulasi penyimpanan file dan mendapatkan URL return f"{url.filename}-{datetime.now().timestamp()}" return None + + +def get_current_time_in_timezone(timezone_str: str = "Asia/Jakarta") -> datetime: + """Get Current Time in Specified Timezone + + Args: + timezone_str (str): Timezone string (e.g., "Asia/Jakarta") + + Returns: + datetime: Current datetime in the specified timezone + """ + try: + tz = ZoneInfo(timezone_str) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + return datetime.now(tz) diff --git a/core/security.py b/core/security.py index c5ab7f5..edfdf9a 100644 --- a/core/security.py +++ b/core/security.py @@ -1,27 +1,29 @@ -from sqlalchemy.orm import Session -from typing import Optional, Tuple from datetime import datetime, timedelta +from typing import Optional, Tuple + +import bcrypt +import jwt +import pytz from fastapi import Depends from fastapi.security import OAuth2PasswordBearer -import bcrypt from pytz import timezone -import pytz from sqlalchemy import delete, or_, select +from sqlalchemy.orm import Session from sqlalchemy.orm import Session as SQLAlchemySession + from models import get_db_sync from models.RefreshToken import RefreshToken from models.Token import Token -import jwt from models.User import User +from schemas.auth import AuthorizationStatusEnum from settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, + ALGORITHM, REFRESH_TOKEN_EXPIRE_MINUTES, SECRET_KEY, - ALGORITHM, TZ, ) - oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token/", auto_error=False) @@ -110,3 +112,20 @@ def invalidate_token(db: SQLAlchemySession, token: str): stmt = delete(Token).where(or_(Token.expired_at <= now, Token.token == token)) db.execute(stmt) db.commit() + + +def check_permissions( + current_user: User | None, required_participant_type: str +) -> AuthorizationStatusEnum: + """Check if the current user has the required permissions. + Args: + current_user (User | None): The current authenticated user. + required_participant_type (str): The required participant type for access. + Returns: + AuthorizationStatusEnum: The authorization status. + """ + if current_user is None: + return AuthorizationStatusEnum.UNAUTHORIZED + if current_user.participant_type != required_participant_type: + return AuthorizationStatusEnum.FORBIDDEN + return AuthorizationStatusEnum.PASSED diff --git a/main.py b/main.py index a4b9b57..18b5db5 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,8 @@ from routes.streaming import router as streaming_router from routes.voucher import router as voucher_router from routes.speaker_type import router as speaker_type_router +from routes.organizer_type import router as organizer_type_router +from routes.organizer import router as organizer_router from routes.schedule_type import router as schedule_type_router from routes.volunteer import router as volunteer_router @@ -59,6 +61,8 @@ app.include_router(streaming_router) app.include_router(voucher_router) app.include_router(speaker_type_router) +app.include_router(organizer_type_router) +app.include_router(organizer_router) app.include_router(schedule_type_router) app.include_router(volunteer_router) diff --git a/migrations/versions/2025_12_01_1859-6feac25c0745_organizer_feature.py b/migrations/versions/2025_12_01_1859-6feac25c0745_organizer_feature.py new file mode 100644 index 0000000..2020a06 --- /dev/null +++ b/migrations/versions/2025_12_01_1859-6feac25c0745_organizer_feature.py @@ -0,0 +1,59 @@ +"""organizer_feature + +Revision ID: 6feac25c0745 +Revises: b3212d6ebfde +Create Date: 2025-12-01 18:59:00.265790 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "6feac25c0745" +down_revision: Union[str, None] = "b3212d6ebfde" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organizer_type", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "organizer", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("organizer_type_id", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.ForeignKeyConstraint( + ["organizer_type_id"], + ["organizer_type.id"], + ), + sa.UniqueConstraint( + "user_id", "organizer_type_id", name="uq_user_organizer_type" + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("organizer_type") + op.drop_table("organizer") + # ### end Alembic commands ### diff --git a/migrations/versions/2025_12_03_1342-92f9a97e9001_merge_diverged_migrations.py b/migrations/versions/2025_12_03_1342-92f9a97e9001_merge_diverged_migrations.py new file mode 100644 index 0000000..b15feae --- /dev/null +++ b/migrations/versions/2025_12_03_1342-92f9a97e9001_merge_diverged_migrations.py @@ -0,0 +1,29 @@ +"""Merge diverged migrations + +Revision ID: 92f9a97e9001 +Revises: 6feac25c0745, 83852d14430b +Create Date: 2025-12-03 13:42:39.739118 + +""" + +from typing import Sequence, Union + +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision: str = "92f9a97e9001" +down_revision: Union[str, None] = ("6feac25c0745", "83852d14430b") # pyright: ignore[reportAssignmentType] +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/models/Organizer.py b/models/Organizer.py new file mode 100644 index 0000000..2662da3 --- /dev/null +++ b/models/Organizer.py @@ -0,0 +1,33 @@ +import datetime +import uuid +from models import Base +from sqlalchemy import UUID, DateTime, ForeignKey +from sqlalchemy.orm import mapped_column, Mapped, relationship + + +class Organizer(Base): + __tablename__ = "organizer" + + id: Mapped[str] = mapped_column( + "id", UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4 + ) + user_id: Mapped[str] = mapped_column( + "user_id", ForeignKey("user.id"), nullable=False + ) + organizer_type_id: Mapped[str] = mapped_column( + "organizer_type_id", ForeignKey("organizer_type.id"), nullable=True + ) + created_at = mapped_column( + "created_at", + DateTime(timezone=True), + default=datetime.datetime.now(datetime.timezone.utc), + ) + updated_at = mapped_column( + "updated_at", + DateTime(timezone=True), + default=datetime.datetime.now(datetime.timezone.utc), + ) + deleted_at = mapped_column("deleted_at", DateTime(timezone=True), nullable=True) + # Relationships + user = relationship("User", backref="organizer_user") + organizer_type = relationship("OrganizerType", backref="organizer_organizer_type") diff --git a/models/OrganizerType.py b/models/OrganizerType.py new file mode 100644 index 0000000..c47be11 --- /dev/null +++ b/models/OrganizerType.py @@ -0,0 +1,13 @@ +import uuid +from sqlalchemy import UUID, String +from models import Base +from sqlalchemy.orm import mapped_column, Mapped + + +class OrganizerType(Base): + __tablename__ = "organizer_type" + + id: Mapped[str] = mapped_column( + "id", UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column("name", String) diff --git a/repository/organizer.py b/repository/organizer.py new file mode 100644 index 0000000..4f47457 --- /dev/null +++ b/repository/organizer.py @@ -0,0 +1,156 @@ +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from typing import Sequence, Literal +from sqlalchemy.orm import Session +from models.Organizer import Organizer +from models.OrganizerType import OrganizerType +from models.User import User +from core.helper import get_current_time_in_timezone +from settings import TZ +from core.log import logger +from schemas.organizer import ( + OrganizersByTypeAll, + organizers_by_type_response_from_models, +) + + +def insert_organizer( + db: Session, user: User, organizer_type: OrganizerType +) -> Organizer: + try: + logger.info("Creating organizer in the database") + + existing = db.execute( + select(Organizer).where( + (Organizer.user_id == user.id) + & (Organizer.organizer_type_id == organizer_type.id) + ) + ).scalar() + + if existing: + logger.warning( + f"Organizer already exists for user {user.id} with type {organizer_type.id}" + ) + raise ValueError( + f"User {user.id} is already an organizer of type {organizer_type.id}" + ) + + current_datetime = get_current_time_in_timezone(TZ) + new_organizer = Organizer( + user=user, + organizer_type=organizer_type, + created_at=current_datetime, + updated_at=current_datetime, + ) + db.add(new_organizer) + db.commit() + db.refresh(new_organizer) + logger.info(f"Organizer created with ID: {new_organizer.id}") + return new_organizer + except IntegrityError as e: + logger.error(f"Integrity constraint violation: {e}") + db.rollback() + raise ValueError("User and organizer type combination already exists") + except Exception as e: + logger.error(f"Error creating organizer: {e}") + db.rollback() + raise e + + +def get_organizer_by_id(db: Session, id: str) -> Organizer | None: + stmt = select(Organizer).where(Organizer.id == id) + return db.execute(stmt).scalar() + + +def get_organizer_by_user_id(db: Session, user_id: str) -> Organizer | None: + stmt = select(Organizer).where(Organizer.user_id == user_id) + return db.execute(stmt).scalar() + + +def delete_organizer_data(db: Session, organizer: Organizer) -> None: + logger.info(f"Deleting organizer with ID: {organizer.id}") + try: + db.delete(organizer) + db.commit() + logger.info(f"Organizer with ID: {organizer.id} deleted successfully") + except Exception as e: + logger.error(f"Error deleting organizer with ID: {organizer.id} - {e}") + db.rollback() + raise e + + +def get_organizers_by_type(db: Session): + try: + stmt = select(OrganizerType).outerjoin(Organizer).distinct() + organizer_types: Sequence[OrganizerType] = db.execute(stmt).scalars().all() + logger.info(f"Fetched {len(organizer_types)} organizer types") + result = [] + for org_type in organizer_types: + organizers = ( + db.execute( + select(Organizer) + .join(Organizer.user) + .where((Organizer.organizer_type_id == org_type.id)) + ) + .scalars() + .all() + ) + if organizers: + result.append( + organizers_by_type_response_from_models(org_type, organizers) + ) + logger.debug( + f"Fetched {len(organizers)} organizers for type {org_type.name}" + ) + return OrganizersByTypeAll(results=result) + except Exception as e: + logger.error(f"Error fetching organizers by type: {e}") + raise e + + +def update_organizer_data( + db: Session, organizer: Organizer, user: User, organizer_type: OrganizerType +) -> Organizer: + now = get_current_time_in_timezone(TZ) + try: + organizer.user = user + organizer.organizer_type = organizer_type + organizer.updated_at = now + db.commit() + db.refresh(organizer) + logger.info(f"Organizer with ID: {organizer.id} updated successfully") + return organizer + except Exception as e: + logger.error(f"Error updating organizer with ID: {organizer.id} - {e}") + db.rollback() + raise e + + +def get_all_organizers( + db: Session, search: str | None = None, order_dir: Literal["asc", "desc"] = "asc" +) -> Sequence[Organizer]: + """Fetch all organizers with optional search and ordering.""" + stmt = select(Organizer) + if search: + search_pattern = f"%{search}%" + stmt = stmt.join(User, Organizer.user).where( + (User.username.ilike(search_pattern)) + | (User.first_name.ilike(search_pattern)) + | (User.last_name.ilike(search_pattern)) + | (User.email.ilike(search_pattern)) + ) + if order_dir == "asc": + stmt = stmt.order_by(Organizer.updated_at.asc()) + else: + stmt = stmt.order_by(Organizer.updated_at.desc()) + organizers = db.scalars(stmt).all() + return organizers + + +def get_organizer_by_type( + db: Session, organizer_type: OrganizerType +) -> Sequence[Organizer]: + """Fetch organizers by organizer type ID.""" + stmt = select(Organizer).where(Organizer.organizer_type_id == OrganizerType.id) + organizers = db.scalars(stmt).all() + return organizers diff --git a/repository/organizer_type.py b/repository/organizer_type.py new file mode 100644 index 0000000..561f754 --- /dev/null +++ b/repository/organizer_type.py @@ -0,0 +1,39 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session +from typing import Sequence +from core.log import logger +from models.OrganizerType import OrganizerType + + +def insert_initial_organizer_types(db: Session) -> None: + """Insert predefined organizer types into the database.""" + logger.info("Inserting initial organizer types...") + try: + initial_types = [ + OrganizerType(name="Lead Organizer"), + OrganizerType(name="Field Coordinator"), + OrganizerType(name="Programs"), + OrganizerType(name="Website"), + OrganizerType(name="Participant Experience"), + OrganizerType(name="Logistics"), + OrganizerType(name="Creative"), + ] + db.add_all(initial_types) + db.commit() + logger.info("Initial organizer types inserted successfully.") + except Exception as e: + db.rollback() + logger.error(f"Failed to insert initial organizer types: {e}") + raise + + +def get_all_organizer_types(db: Session) -> Sequence[OrganizerType]: + """Retrieve all organizer types from the database.""" + stmt = select(OrganizerType).order_by(OrganizerType.name.asc()) + return db.execute(stmt).scalars().all() + + +def get_organizer_type_by_id(db: Session, id: str) -> OrganizerType | None: + """Retrieve an organizer type by its ID.""" + stmt = select(OrganizerType).where(OrganizerType.id == id) + return db.execute(stmt).scalar() diff --git a/routes/organizer.py b/routes/organizer.py new file mode 100644 index 0000000..096abd2 --- /dev/null +++ b/routes/organizer.py @@ -0,0 +1,422 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from core.file import get_file + +from core.log import logger +from core.responses import ( + BadRequest, + Forbidden, + InternalServerError, + NotFound, + Ok, + Unauthorized, + common_response, +) + +from core.security import check_permissions, get_current_user +from models import get_db_sync +from models.User import MANAGEMENT_PARTICIPANT, User +from repository.organizer import ( + delete_organizer_data, + get_organizer_by_id, + get_organizers_by_type, + get_organizer_by_user_id, + insert_organizer, + update_organizer_data, + get_all_organizers, + get_organizer_by_type, + organizers_by_type_response_from_models, +) +from repository.organizer_type import get_organizer_type_by_id +from repository.user import get_user_by_id +from schemas.auth import AuthorizationStatusEnum +from schemas.organizer import ( + OrganizerCreateRequest, + OrganizerUpdateRequest, + organizer_detail_response_from_model, + organizer_response_item_from_model, + organizer_detail_response_list_from_models, + OrganizerQuery, + OrganizerDetailResponseList, + OrganizersByTypeAll, + OrganizersByType, + OrganizerResponseItem, + OrganizerDetailResponse, +) +from schemas.common import ( + InternalServerErrorResponse, + BadRequestResponse, + UnauthorizedResponse, + NotFoundResponse, + ForbiddenResponse, + OkResponse, +) + +router = APIRouter(prefix="/organizer", tags=["Organizer"]) + + +@router.get( + "/type", + responses={ + "200": {"model": OrganizersByTypeAll}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def get_organizers_grouped_by_type( + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info("Fetching organizers grouped by type") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + data = get_organizers_by_type(db=db) + if data is None: + logger.error("No organizers found grouped by type") + return common_response(NotFound(message="No organizers found")) + return common_response(Ok(data=data.model_dump())) + except Exception as e: + logger.error(f"Error fetching organizers by type: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.get( + "/public", + responses={ + "200": {"model": OrganizerDetailResponseList}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def get_organizers_public( + db: Session = Depends(get_db_sync), + query: OrganizerQuery = Depends(), +): + logger.info("Fetching all organizers") + try: + data = get_all_organizers(db=db, search=query.search, order_dir=query.order_dir) + if data is None: + return common_response(NotFound(message="No organizers found")) + + model_data = organizer_detail_response_list_from_models(data) + return common_response(Ok(data=model_data.model_dump())) + except Exception as e: + logger.error(f"Error fetching organizers by type: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.get( + "/", + responses={ + "200": {"model": OrganizerDetailResponseList}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def get_organizers( + db: Session = Depends(get_db_sync), + query: OrganizerQuery = Depends(), + current_user: User | None = Depends(get_current_user), +): + logger.info("Fetching all organizers") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + data = get_all_organizers(db=db, search=query.search, order_dir=query.order_dir) + if data is None: + return common_response(NotFound(message="No organizers found")) + + model_data = organizer_detail_response_list_from_models(data) + return common_response(Ok(data=model_data.model_dump())) + except Exception as e: + logger.error(f"Error fetching organizers by type: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.get( + "/type/{organizer_type_id}", + responses={ + "200": {"model": OrganizersByType}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def find_organizer_by_type( + organizer_type_id: str, + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info("Fetching all organizers") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + orgnizer_type = get_organizer_type_by_id(db=db, id=organizer_type_id) + if not orgnizer_type: + logger.error(f"Organizer type with ID {organizer_type_id} not found") + return common_response(NotFound(message="Organizer type not found")) + data = get_organizer_by_type(db=db, organizer_type=orgnizer_type) + if data is None: + return common_response(NotFound(message="No organizers found")) + + model_data = organizers_by_type_response_from_models( + organizer_type=orgnizer_type, organizers=data + ) + return common_response(Ok(data=model_data.model_dump())) + except Exception as e: + logger.error(f"Error fetching organizers by type: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.post( + "/", + responses={ + "200": {"model": OrganizerResponseItem}, + "400": {"model": BadRequestResponse}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def create_organizer( + create_request: OrganizerCreateRequest, + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info("Creating a new organizer") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + organizer_type = get_organizer_type_by_id(db, create_request.organizer_type_id) + + if not organizer_type: + logger.error( + f"Organizer type with ID {create_request.organizer_type_id} not found" + ) + return common_response(NotFound(message="Organizer type not found")) + + user = get_user_by_id(db, create_request.user_id) + if not user: + logger.error(f"User with ID {create_request.user_id} not found") + return common_response(NotFound(message="User not found")) + + existing_organizer = get_organizer_by_user_id(db, create_request.user_id) + if existing_organizer: + logger.error( + f"User with ID {create_request.user_id} is already an organizer" + ) + return common_response( + BadRequest( + custom_response=f"Organizer for user id {create_request.user_id} already exists" + ) + ) + new_organizer = insert_organizer(db, user, organizer_type) + response = organizer_response_item_from_model(new_organizer) + return common_response(Ok(data=response.model_dump())) + logger.info(f"Organizer created with ID: {new_organizer.id}") + except ValueError as ve: + logger.error(f"Value error: User already an organizer of this type - {ve}") + return common_response( + BadRequest(custom_response="User already an organizer of this type") + ) + except Exception as e: + logger.error(f"Error creating organizer: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.get( + "/{organizer_id}", + responses={ + "200": {"model": OrganizerDetailResponse}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def find_organizer_by_id( + organizer_id: str, + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info(f"Fetching organizer with ID: {organizer_id}") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + organizer = get_organizer_by_id(db, organizer_id) + if not organizer: + logger.error(f"Organizer with ID {organizer_id} not found") + return common_response(NotFound(message="Organizer not found")) + response = organizer_detail_response_from_model(organizer) + return common_response(Ok(data=response.model_dump())) + except Exception as e: + logger.error(f"Error fetching organizer: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.put( + "/{organizer_id}", + responses={ + "200": {"model": OrganizerDetailResponse}, + "400": {"model": BadRequestResponse}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def update_organizer_by_id( + organizer_id: str, + payload: OrganizerUpdateRequest, + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info(f"Updating organizer with ID: {organizer_id}") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + organizer = get_organizer_by_id(id=organizer_id, db=db) + if not organizer: + logger.error(f"Organizer with ID {organizer_id} not found") + return common_response(NotFound(message="Organizer not found")) + organizer_type_id = payload.organizer_type_id or organizer.organizer_type_id + organizer_type = get_organizer_type_by_id(db=db, id=organizer_type_id) + + if not organizer_type: + logger.error(f"Organizer type with ID {organizer_type_id} not found") + return common_response(NotFound(message="Organizer type not found")) + + user_id = payload.user_id or organizer.user_id + user = get_user_by_id(db=db, id=user_id) + if not user: + logger.error(f"User with ID {user_id} not found") + return common_response(NotFound(message="User not found")) + existing_organizer = get_organizer_by_user_id(db, user.id) + + if existing_organizer is not None and existing_organizer.id != organizer.id: + logger.error(f"User with ID {user_id} is already an organizer") + return common_response( + BadRequest( + custom_response=f"Organizer for user id {user_id} already exists" + ) + ) + update_organizer = update_organizer_data( + db=db, + organizer=organizer, + user=user, + organizer_type=organizer_type, + ) + response = organizer_detail_response_from_model(update_organizer) + return common_response(Ok(data=response.model_dump())) + except Exception as e: + logger.error(f"Error updating organizer: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.delete( + "/{organizer_id}", + responses={ + "200": {"model": OkResponse}, + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def delete_organizer( + organizer_id: str, + db: Session = Depends(get_db_sync), + current_user: User | None = Depends(get_current_user), +): + logger.info(f"Deleting organizer with ID: {organizer_id}") + auth_status = check_permissions(current_user, MANAGEMENT_PARTICIPANT) + if auth_status == AuthorizationStatusEnum.UNAUTHORIZED: + return common_response(Unauthorized(message="Unauthorized")) + if auth_status == AuthorizationStatusEnum.FORBIDDEN: + return common_response( + Forbidden(custom_response="Forbidden: Insufficient permissions") + ) + try: + organizer = get_organizer_by_id(id=organizer_id, db=db) + if not organizer: + logger.error(f"Organizer with ID {organizer_id} not found") + return common_response(NotFound(message="Organizer not found")) + delete_organizer_data(db, organizer) + logger.info(f"Organizer with ID: {organizer_id} deleted successfully") + return common_response(Ok(data={"message": "Organizer deleted successfully"})) + except Exception as e: + logger.error(f"Error deleting organizer: {e}") + return common_response(InternalServerError(error=str(e))) + + +@router.get( + "/{organizer_id}/profile-picture", + response_class=FileResponse, + responses={ + "401": {"model": UnauthorizedResponse}, + "403": {"model": ForbiddenResponse}, + "404": {"model": NotFoundResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +def get_organizer_profile_picture( + organizer_id: str, db: Session = Depends(get_db_sync) +): + logger.info(f"Getting profile picture for organizer ID: {organizer_id}") + try: + organizer = get_organizer_by_id(db, organizer_id) + if not organizer: + logger.error(f"Organizer with ID {organizer_id} not found") + return common_response(NotFound(message="Organizer not found")) + + if organizer.user.profile_picture is None: + logger.error(f"Organizer with ID {organizer_id} has no profile picture") + return common_response(NotFound(message="Profile picture not found")) + profile_picture = get_file(organizer.user.profile_picture) + if profile_picture is None: + logger.error( + f"Profile picture file for organizer ID {organizer_id} not found" + ) + return common_response(NotFound(message="Profile picture not found")) + return profile_picture + except Exception as e: + logger.error(f"Error fetching profile picture: {e}") + return common_response(InternalServerError(error=str(e))) diff --git a/routes/organizer_type.py b/routes/organizer_type.py new file mode 100644 index 0000000..eafa112 --- /dev/null +++ b/routes/organizer_type.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from core.responses import Ok, common_response, InternalServerError +from schemas.common import ( + InternalServerErrorResponse, +) +from models import get_db_sync +from schemas.organizer_type import ( + OrganizerTypeAllResponse, + organizer_type_all_response_from_models, +) +from repository.organizer_type import get_all_organizer_types +from core.log import logger + +router = APIRouter(prefix="/organizer-type", tags=["Organizer Type"]) + + +@router.get( + "/", + responses={ + "200": {"model": OrganizerTypeAllResponse}, + "500": {"model": InternalServerErrorResponse}, + }, +) +async def get_speaker(db: Session = Depends(get_db_sync)): + try: + all_speaker_types = get_all_organizer_types(db=db) + response = organizer_type_all_response_from_models(all_speaker_types) + return common_response(Ok(data=response.model_dump())) + except Exception as e: + logger.error(f"Error retrieving organizer types: {e}") + return common_response(InternalServerError(error=str(e))) diff --git a/routes/streaming.py b/routes/streaming.py index e3ca5a9..c424ae2 100644 --- a/routes/streaming.py +++ b/routes/streaming.py @@ -1,14 +1,13 @@ -from core.responses import handle_http_exception +import json import traceback -from core.log import logger from datetime import datetime -import json from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request from pytz import timezone from sqlalchemy.orm import Session +from core.log import logger from core.mux_service import mux_service from core.responses import ( BadRequest, @@ -17,6 +16,7 @@ Ok, Unauthorized, common_response, + handle_http_exception, ) from core.security import get_user_from_token, oauth2_scheme from models import get_db_sync @@ -70,14 +70,14 @@ async def get_stream_playback( if stream_asset.schedule.deleted_at: return common_response(NotFound(message="Stream not found")) - if stream_asset.status not in [ - StreamStatus.READY.value, - StreamStatus.STREAMING.value, - StreamStatus.ENDED.value, - ]: - return common_response( - BadRequest(message="Stream is not ready for playback") - ) + # if stream_asset.status not in [ + # StreamStatus.READY.value, + # StreamStatus.STREAMING.value, + # StreamStatus.ENDED.value, + # ]: + # return common_response( + # BadRequest(message="Stream is not ready for playback") + # ) # Use asset playback ID for ended streams (replay/recording) # Use live stream playback ID for active/streaming streams diff --git a/routes/tests/test_organizer.py b/routes/tests/test_organizer.py new file mode 100644 index 0000000..77aa463 --- /dev/null +++ b/routes/tests/test_organizer.py @@ -0,0 +1,533 @@ +import shutil +import uuid +import alembic.config +from unittest import IsolatedAsyncioTestCase + +from fastapi.testclient import TestClient +from sqlalchemy import select +from core.security import generate_token_from_user +from models import engine, db, get_db_sync, get_db_sync_for_test +from models.Organizer import Organizer +from models.OrganizerType import OrganizerType +from models.User import MANAGEMENT_PARTICIPANT, User +from main import app +from settings import FILE_STORAGE_PATH + + +class TestOrganizer(IsolatedAsyncioTestCase): + def setUp(self) -> None: + alembic_args = ["upgrade", "head"] + alembic.config.main(argv=alembic_args) + # connect to the database + self.connection = engine.connect() + + # begin a non-ORM transaction + self.trans = self.connection.begin() + + # bind an individual Session to the connection, selecting + # "create_savepoint" join_transaction_mode + self.db = db(bind=self.connection, join_transaction_mode="create_savepoint") + + async def test_get_all_organizers(self): + # Given + user_management = User( + id="123e4567-e89b-12d3-a456-426614174000", + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + user_1 = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@pycon.id", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + self.db.add(user_1) + organizer1 = Organizer( + user=user_1, + organizer_type=organizer_type, + ) + self.db.add(organizer1) + self.db.commit() + + user_2 = User( + username="Jane Organizer", + first_name="Jane", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="jane@pycon.id", + instagram_username="http://instagram.com/janeorg", + twitter_username="http://x.com/janeorg", + ) + self.db.add(user_2) + organizer2 = Organizer( + user=user_2, + organizer_type=organizer_type, + ) + self.db.add(organizer2) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When + response = client.get( + "/organizer/", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Expect + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["results"]) + + async def test_get_organizer_by_id(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + user_non_management = User( + username="non_admin", + participant_type=None, + ) + self.db.add(user_non_management) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + self.db.add(user) + organizer = Organizer( + user=user, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When 1 - Management user can get organizer + response = client.get( + f"/organizer/{organizer.id}", headers={"Authorization": f"Bearer {token}"} + ) + + # Expect 1 + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], str(organizer.id)) + + # When 2 - Non-management user cannot get organizer + (token, _) = await generate_token_from_user( + db=self.db, user=user_non_management + ) + response = client.get( + f"/organizer/{organizer.id}", headers={"Authorization": f"Bearer {token}"} + ) + + # Expect 2 + self.assertEqual(response.status_code, 403) + + async def test_create_organizer(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + user_non_management = User( + username="non_admin", + participant_type=None, + ) + self.db.add(user_non_management) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When 1 - Management user creates organizer + response = client.post( + "/organizer/", + headers={"Authorization": f"Bearer {token}"}, + json={ + "user_id": str(user_non_management.id), + "organizer_type_id": str(organizer_type.id), + }, + ) + + # Expect 1 + self.assertEqual(response.status_code, 200) + stmt = select(Organizer).where(Organizer.user_id == user_non_management.id) + organizer = self.db.execute(stmt).scalar() + self.assertIsNotNone(organizer) + if organizer is not None: + self.assertEqual(organizer.organizer_type_id, organizer_type.id) + + # When 2 - Non-management user cannot create organizer + user_another = User( + username="another_user", + participant_type=None, + ) + self.db.add(user_another) + self.db.commit() + + (token, _) = await generate_token_from_user( + db=self.db, user=user_non_management + ) + response = client.post( + "/organizer/", + headers={"Authorization": f"Bearer {token}"}, + json={ + "user_id": str(user_another.id), + "organizer_type_id": str(organizer_type.id), + }, + ) + + # Expect 2 + self.assertEqual(response.status_code, 403) + + async def test_create_organizer_duplicate_user(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + user_organizer = User( + username="user_organizer", + participant_type=None, + ) + self.db.add(user_organizer) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + organizer = Organizer( + user=user_organizer, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When - Try to create organizer for user who is already an organizer + response = client.post( + "/organizer/", + headers={"Authorization": f"Bearer {token}"}, + json={ + "user_id": str(user_organizer.id), + "organizer_type_id": str(organizer_type.id), + }, + ) + + # Expect + self.assertEqual(response.status_code, 400) + + async def test_update_organizer(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + user_non_management = User( + username="non_admin", + participant_type=None, + ) + self.db.add(user_non_management) + organizer_type1 = OrganizerType(name="Core Team") + organizer_type2 = OrganizerType(name="Volunteer") + self.db.add(organizer_type1) + self.db.add(organizer_type2) + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + self.db.add(user) + organizer = Organizer( + user=user, + organizer_type=organizer_type1, + ) + self.db.add(organizer) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When 1 - Management user updates organizer + response = client.put( + f"/organizer/{organizer.id}", + headers={"Authorization": f"Bearer {token}"}, + json={ + "organizer_type_id": str(organizer_type2.id), + }, + ) + print(response.json()) + + # Expect 1 + self.assertEqual(response.status_code, 200) + self.db.refresh(organizer) + self.assertEqual(organizer.organizer_type_id, organizer_type2.id) + + # When 2 - Non-management user cannot update organizer + (token, _) = await generate_token_from_user( + db=self.db, user=user_non_management + ) + response = client.put( + f"/organizer/{organizer.id}", + headers={"Authorization": f"Bearer {token}"}, + json={ + "organizer_type_id": str(organizer_type1.id), + }, + ) + + # Expect 2 + self.assertEqual(response.status_code, 403) + + async def test_delete_organizer(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + user_non_management = User( + username="non_admin", + participant_type=None, + ) + self.db.add(user_non_management) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + organizer = Organizer( + id=uuid.uuid4(), + user=user, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When 1 - Management user deletes organizer + response = client.delete( + f"/organizer/{str(organizer.id)}", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Expect 1 + self.assertEqual(response.status_code, 200) + stmt = select(Organizer).where(Organizer.id == organizer.id) + deleted_organizer = self.db.execute(stmt).scalar() + self.assertIsNone(deleted_organizer) + + # When 2 - Non-management user cannot delete organizer + (token, _) = await generate_token_from_user( + db=self.db, user=user_non_management + ) + response = client.delete( + "/organizer/123e4567-e89b-12d3-a456-426614174000", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Expect 2 + self.assertEqual(response.status_code, 403) + + async def test_get_organizer_profile_picture(self): + # Given + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture=None, + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + organizer = Organizer( + id=uuid.uuid4(), + user=user, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When 1 - Profile picture not found + response = client.get( + f"/organizer/{str(organizer.id)}/profile-picture", + ) + + # Expect 1 + self.assertEqual(response.status_code, 404) + + # Given 2 - Copy profile picture file + shutil.copyfile( + "./routes/tests/data/bandungpy.jpg", + f"./{FILE_STORAGE_PATH}/bandungpy.jpg", + ) + user.profile_picture = "bandungpy.jpg" + self.db.add(user) + self.db.commit() + + # When 2 - Profile picture found + response = client.get( + f"/organizer/{str(organizer.id)}/profile-picture", + ) + + # Expect 2 + self.assertEqual(response.status_code, 200) + + async def test_find_organizer_by_id_not_found(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When - Management user tries to get non-existent organizer + response = client.get( + "/organizer/123e4567-e89b-12d3-a456-426614174999", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Expect + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["message"], "Organizer not found") + + async def test_find_organizer_by_id_response_structure(self): + # Given + user_management = User( + username="admin", + participant_type=MANAGEMENT_PARTICIPANT, + ) + self.db.add(user_management) + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + self.db.add(user) + organizer = Organizer( + user=user, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + + (token, _) = await generate_token_from_user(db=self.db, user=user_management) + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When + response = client.get( + f"/organizer/{organizer.id}", + headers={"Authorization": f"Bearer {token}"}, + ) + + # Expect + self.assertEqual(response.status_code, 200) + response_data = response.json() + self.assertIn("id", response_data) + self.assertIn("user", response_data) + self.assertIn("organizer_type", response_data) + self.assertEqual(response_data["id"], str(organizer.id)) + + async def test_find_organizer_by_id_without_authorization_header(self): + # Given + organizer_type = OrganizerType(name="Core Team") + self.db.add(organizer_type) + user = User( + username="John Organizer", + first_name="John", + last_name="Organizer", + bio="An organizer", + profile_picture="http://example.com/photo.jpg", + email="john@gmail.com", + instagram_username="http://instagram.com/johnorg", + twitter_username="http://x.com/johnorg", + ) + self.db.add(user) + organizer = Organizer( + user=user, + organizer_type=organizer_type, + ) + self.db.add(organizer) + self.db.commit() + + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When - No authorization header provided + response = client.get( + f"/organizer/{organizer.id}", + ) + + # Expect + self.assertEqual(response.status_code, 401) + + def tearDown(self): + self.db.close() + + # rollback - everything that happened with the + # Session above (including calls to commit()) + # is rolled back. + self.trans.rollback() + + # return connection to the Engine + self.connection.close() diff --git a/routes/tests/test_organizer_type.py b/routes/tests/test_organizer_type.py new file mode 100644 index 0000000..9360c66 --- /dev/null +++ b/routes/tests/test_organizer_type.py @@ -0,0 +1,55 @@ +import alembic.config +from unittest import IsolatedAsyncioTestCase + +from fastapi.testclient import TestClient +from models import engine, db, get_db_sync, get_db_sync_for_test +from models.OrganizerType import OrganizerType +from main import app +from schemas.organizer_type import organizer_type_all_response_from_models + + +class TestOrganizerType(IsolatedAsyncioTestCase): + def setUp(self) -> None: + alembic_args = ["upgrade", "head"] + alembic.config.main(argv=alembic_args) + # connect to the database + self.connection = engine.connect() + + # begin a non-ORM transaction + self.trans = self.connection.begin() + + # bind an individual Session to the connection, selecting + # "create_savepoint" join_transaction_mode + self.db = db(bind=self.connection, join_transaction_mode="create_savepoint") + + async def test_get_organizer_type_all(self): + # Given + ot1 = OrganizerType(name="Committee Member") + self.db.add(ot1) + ot2 = OrganizerType(name="Volunteer") + self.db.add(ot2) + self.db.commit() + + app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + client = TestClient(app) + + # When + response = client.get("/organizer-type/") + data = response.json() + + # Then + self.assertDictEqual( + data, + organizer_type_all_response_from_models([ot1, ot2]).model_dump(), + ) + + def tearDown(self): + self.db.close() + + # rollback - everything that happened with the + # Session above (including calls to commit()) + # is rolled back. + self.trans.rollback() + + # return connection to the Engine + self.connection.close() diff --git a/routes/tests/test_streaming.py b/routes/tests/test_streaming.py index f55477e..4db3aa6 100644 --- a/routes/tests/test_streaming.py +++ b/routes/tests/test_streaming.py @@ -1,22 +1,22 @@ import uuid -import alembic.config from datetime import datetime, timedelta from unittest import IsolatedAsyncioTestCase from unittest.mock import patch +import alembic.config from fastapi.testclient import TestClient from core.security import generate_token_from_user -from models import engine, db, get_db_sync, get_db_sync_for_test -from models.User import MANAGEMENT_PARTICIPANT, User -from models.Speaker import Speaker -from models.SpeakerType import SpeakerType +from main import app +from models import db, engine, get_db_sync, get_db_sync_for_test from models.Room import Room -from models.ScheduleType import ScheduleType from models.Schedule import Schedule +from models.ScheduleType import ScheduleType +from models.Speaker import Speaker +from models.SpeakerType import SpeakerType from models.Stream import Stream, StreamStatus +from models.User import MANAGEMENT_PARTICIPANT, User from schemas.user_profile import ParticipantType -from main import app class TestStreaming(IsolatedAsyncioTestCase): @@ -447,54 +447,54 @@ async def test_get_stream_playback_not_found(self): # Expect self.assertEqual(response.status_code, 404) - async def test_get_stream_playback_pending_status(self): - # Given - start_time = datetime.now() + timedelta(hours=1) - end_time = start_time + timedelta(hours=1) - - schedule = Schedule( - title="Pending Stream", - speaker_id=self.speaker.id, - room_id=self.room.id, - schedule_type_id=self.schedule_type.id, - description="Test description", - presentation_language="English", - slide_language="English", - tags=["python"], - start=start_time, - end=end_time, - ) - self.db.add(schedule) - self.db.commit() - - stream = Stream( - schedule_id=schedule.id, - is_public=True, - mux_live_stream_id="mux_stream_123", - mux_playback_id="playback_123", - mux_stream_key="stream_key_123", - status=StreamStatus.PENDING, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - self.db.add(stream) - self.db.commit() - - token, _ = await generate_token_from_user( - db=self.db, user=self.user_participant - ) - app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) - client = TestClient(app) - - # When - response = client.get( - f"/streaming/{stream.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - - # Expect - self.assertEqual(response.status_code, 400) - self.assertIn("not ready for playback", response.json()["message"]) + # async def test_get_stream_playback_pending_status(self): + # # Given + # start_time = datetime.now() + timedelta(hours=1) + # end_time = start_time + timedelta(hours=1) + + # schedule = Schedule( + # title="Pending Stream", + # speaker_id=self.speaker.id, + # room_id=self.room.id, + # schedule_type_id=self.schedule_type.id, + # description="Test description", + # presentation_language="English", + # slide_language="English", + # tags=["python"], + # start=start_time, + # end=end_time, + # ) + # self.db.add(schedule) + # self.db.commit() + + # stream = Stream( + # schedule_id=schedule.id, + # is_public=True, + # mux_live_stream_id="mux_stream_123", + # mux_playback_id="playback_123", + # mux_stream_key="stream_key_123", + # status=StreamStatus.PENDING, + # created_at=datetime.now(), + # updated_at=datetime.now(), + # ) + # self.db.add(stream) + # self.db.commit() + + # token, _ = await generate_token_from_user( + # db=self.db, user=self.user_participant + # ) + # app.dependency_overrides[get_db_sync] = get_db_sync_for_test(db=self.db) + # client = TestClient(app) + + # # When + # response = client.get( + # f"/streaming/{stream.id}", + # headers={"Authorization": f"Bearer {token}"}, + # ) + + # # Expect + # self.assertEqual(response.status_code, 400) + # self.assertIn("not ready for playback", response.json()["message"]) async def test_get_stream_playback_deleted_schedule(self): # Given diff --git a/schemas/auth.py b/schemas/auth.py index 61f2154..2ed1a07 100644 --- a/schemas/auth.py +++ b/schemas/auth.py @@ -1,6 +1,7 @@ from typing import Optional from fastapi import Query from pydantic import BaseModel, Field +from enum import Enum class LoginRequest(BaseModel): @@ -88,3 +89,9 @@ class GoogleVerifiedResponse(BaseModel): refresh_token: str is_new_user: bool google_email: str + + +class AuthorizationStatusEnum(str, Enum): + FORBIDDEN = "forbidden" + UNAUTHORIZED = "unauthorized" + PASSED = "passed" diff --git a/schemas/common.py b/schemas/common.py index 984d5d3..8433d36 100644 --- a/schemas/common.py +++ b/schemas/common.py @@ -4,6 +4,10 @@ NoContentResponse = None +class OkResponse(BaseModel): + message: str = "Ok" + + class UnauthorizedResponse(BaseModel): message: str = "Unauthorized" diff --git a/schemas/organizer.py b/schemas/organizer.py new file mode 100644 index 0000000..fc01ec1 --- /dev/null +++ b/schemas/organizer.py @@ -0,0 +1,147 @@ +from pydantic import BaseModel +from typing import Optional, Literal, Sequence + +from fastapi import Query +from models.Organizer import Organizer +from models.OrganizerType import OrganizerType +from models.User import User + + +class OrganizerQuery(BaseModel): + search: Optional[str] = Query(None, description="Search by organizer name") + order_dir: Literal["asc", "desc"] = Query( + "asc", description="Order direction: asc or desc" + ) + + +class OrganizerDetailUser(BaseModel): + id: str + first_name: str | None = None + last_name: str | None = None + username: str | None = None + bio: str | None = None + profile_picture: str | None = None + email: str | None = None + website: str | None = None + facebook_username: str | None = None + linkedin_username: str | None = None + instagram_username: str | None = None + twitter_username: str | None = None + + +class OrganizerDetailType(BaseModel): + id: str + name: str + + +class OrganizerDetailResponse(BaseModel): + id: str + user: OrganizerDetailUser + organizer_type: OrganizerDetailType + + +class OrganizerDetailResponseList(BaseModel): + results: list[OrganizerDetailResponse] + + +class OrganizerCreateRequest(BaseModel): + user_id: str + organizer_type_id: str + + +class OrganizerUpdateRequest(BaseModel): + user_id: str | None = None + organizer_type_id: str | None = None + + +class OrganizerResponseItem(BaseModel): + id: str + user_id: str + organizer_type_id: str + created_at: str | None = None + updated_at: str | None = None + + +class OrganizersByType(BaseModel): + organizer_type: OrganizerDetailType + organizers: list[OrganizerDetailUser] + + +class OrganizersByTypeAll(BaseModel): + results: list[OrganizersByType] + + +def organizer_response_item_from_model(organizer: Organizer) -> OrganizerResponseItem: + """Convert Organizer ORM model to OrganizerResponseItem Pydantic model.""" + return OrganizerResponseItem( + id=str(organizer.id), + user_id=str(organizer.user_id), + organizer_type_id=str(organizer.organizer_type_id), + created_at=organizer.created_at.isoformat() if organizer.created_at else None, + updated_at=organizer.updated_at.isoformat() if organizer.updated_at else None, + ) + + +def organizer_detail_user_from_model(organizer: Organizer) -> OrganizerDetailUser: + """Convert Organizer ORM model to OrganizerDetailUser Pydantic model.""" + user: User = organizer.user + + return OrganizerDetailUser( + id=str(user.id), + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + bio=user.bio, + profile_picture=user.profile_picture, + email=user.email if user.share_my_email_and_phone_number else None, + website=user.website if user.share_my_public_social_media else None, + facebook_username=user.facebook_username + if user.share_my_public_social_media + else None, + linkedin_username=user.linkedin_username + if user.share_my_public_social_media + else None, + instagram_username=user.instagram_username + if user.share_my_public_social_media + else None, + twitter_username=user.twitter_username + if user.share_my_public_social_media + else None, + ) + + +def organizer_detail_response_from_model( + organizer: Organizer, +) -> OrganizerDetailResponse: + """Convert Organizer ORM model to OrganizerDetailResponse Pydantic model.""" + organizer_type = organizer.organizer_type + return OrganizerDetailResponse( + id=str(organizer.id), + user=organizer_detail_user_from_model(organizer), + organizer_type=OrganizerDetailType( + id=str(organizer_type.id), + name=organizer_type.name, + ), + ) + + +def organizer_detail_response_list_from_models( + organizers: Sequence[Organizer], +) -> OrganizerDetailResponseList: + """Convert list of Organizer ORM models to OrganizerDetailResponseList Pydantic model.""" + return OrganizerDetailResponseList( + results=[organizer_detail_response_from_model(org) for org in organizers], + ) + + +def organizers_by_type_response_from_models( + organizer_type: OrganizerType, organizers: Sequence[Organizer] +) -> OrganizersByType: + """Convert OrganizerType and list of Organizer ORM models to OrganizersByType Pydantic model.""" + return OrganizersByType( + organizer_type=OrganizerDetailType( + id=str(organizer_type.id), + name=organizer_type.name, + ), + organizers=[organizer_detail_user_from_model(org) for org in organizers], + ) diff --git a/schemas/organizer_type.py b/schemas/organizer_type.py new file mode 100644 index 0000000..6638bd1 --- /dev/null +++ b/schemas/organizer_type.py @@ -0,0 +1,35 @@ +from typing import Sequence + +from pydantic import BaseModel + +from models.OrganizerType import OrganizerType + + +class OrganizerTypeItem(BaseModel): + id: str + name: str + + +class OrganizerTypeAllResponse(BaseModel): + results: list[OrganizerTypeItem] + + +def organizer_type_item_from_model(model: OrganizerType) -> OrganizerTypeItem: + """Convert OrganizerType model to Schema + + Args: + model (OrganizerType): A organizer type model instance. + + Returns: + OrganizerTypeItem: A schema representation of the organizer type. + """ + return OrganizerTypeItem(id=str(model.id), name=model.name) + + +def organizer_type_all_response_from_models( + models: Sequence[OrganizerType], +) -> OrganizerTypeAllResponse: + """Convert a list of OrganizerType models to OrganizerTypeAllResponse schema.""" + return OrganizerTypeAllResponse( + results=[organizer_type_item_from_model(model) for model in models] + )