From 2e4dbc878e0d3828d49b42e5348bd03c55be732a Mon Sep 17 00:00:00 2001 From: jbkyei1 Date: Tue, 31 Mar 2026 23:38:24 +0000 Subject: [PATCH 1/4] feat: Add batch user status update endpoint and related models --- .env.sample | 32 ++-- ...meetings_recurrence_challenges_deadline.py | 37 +++++ .../e89564e882b1_add_missing_tables.py | 79 ++++++++++ README.md | 144 ++++++++++++++---- api/api_models/announcements.py | 1 + api/api_models/coding_challenges.py | 34 +++++ api/api_models/weekly_meetings.py | 35 +++++ api/routes/announcements.py | 25 ++- api/routes/coding_challenges.py | 74 +++++++++ api/routes/profile_page.py | 40 ++++- api/routes/skills.py | 20 ++- api/routes/users.py | 6 +- api/routes/weekly_meetings.py | 74 +++++++++ app.py | 41 ++++- db/__init__.py | 3 + db/models/coding_challenges.py | 27 ++++ db/models/weekly_meetings.py | 22 +++ db/repository/coding_challenges.py | 48 ++++++ db/repository/org_chart.py | 15 +- db/repository/skills.py | 16 ++ db/repository/weekly_meetings.py | 49 ++++++ docker-compose.dev.yml | 7 +- docs/TODO.md | 0 services/auth_service.py | 38 ++++- services/coding_challenge_service.py | 25 +++ services/skill_service.py | 18 +++ services/user_service.py | 8 +- services/weekly_meeting_service.py | 25 +++ update_users_role.py | 55 +++++++ update_users_status.py | 48 ++++++ utils/mail_service.py | 26 +++- 31 files changed, 988 insertions(+), 84 deletions(-) create mode 100644 Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py create mode 100644 Alembic/versions/e89564e882b1_add_missing_tables.py create mode 100644 api/api_models/coding_challenges.py create mode 100644 api/api_models/weekly_meetings.py create mode 100644 api/routes/coding_challenges.py create mode 100644 api/routes/weekly_meetings.py create mode 100644 db/models/coding_challenges.py create mode 100644 db/models/weekly_meetings.py create mode 100644 db/repository/coding_challenges.py create mode 100644 db/repository/weekly_meetings.py delete mode 100644 docs/TODO.md create mode 100644 services/coding_challenge_service.py create mode 100644 services/weekly_meeting_service.py create mode 100644 update_users_role.py create mode 100644 update_users_status.py diff --git a/.env.sample b/.env.sample index 7f9ac7d..e8daf85 100644 --- a/.env.sample +++ b/.env.sample @@ -1,21 +1,29 @@ +# Database Configuration POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres -POSTGRES_DB=github_actions_db +POSTGRES_DB=st_crm_db +POSTGRES_DB_TEST=st_crm_db_test POSTGRES_PORT=5432 POSTGRES_SERVER=localhost + +# Security & Authentication +SECRET=your_secret_key_here +REFRESH_SECRET=your_refresh_secret_here + +# Server Configuration BASE_URL=http://localhost:3000 -EMAIL_SENDER=someawesomeemail@gmail.com -EMAIL_PASSWORD=xxxxxxxxxxx -EMAIL_SERVER=smtp.gmail.com -EMAIL_PORT=587 URL_PATH=/users/reset-password/new-password -SECRET=RMi7R08iX02B8s85QXmXvCK7 -REFRESH_SECRET=2B8s85QXmXvCK7RMi7R08iX0 -EMAIL_PASSWORD=5ioVUFj4KdOF1aR15iLS9HvZ -CLOUDINARY_CLOUD_NAME=Xl9q9TVz88jF8gxrb9XudOX2 -CLOUDINARY_API_KEY=Jfu39X7wCmS05aBd2oJb9j3c -CLOUDINARY_API_SECRET=7W8b0ksC84cQq9n97tXqu8gW - +# Email Configuration +EMAIL_SENDER=your_email@gmail.com +EMAIL_PASSWORD=your_app_password +EMAIL_SERVER=smtp.gmail.com +EMAIL_PORT=587 +# Cloudinary Configuration (for media uploads) +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret +# Production flag (set to "true" in production environments) +PRODUCTION_ENV=false diff --git a/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py b/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py new file mode 100644 index 0000000..f3ba8ac --- /dev/null +++ b/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py @@ -0,0 +1,37 @@ +"""Add scheduled_time/recurrence to meetings, deadline to challenges + +Revision ID: c1d2e3f4a5b6 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-31 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c1d2e3f4a5b6' +down_revision = 'e89564e882b1' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'weekly_meetings', + sa.Column('scheduled_time', sa.TIMESTAMP(timezone=True), nullable=True) + ) + op.add_column( + 'weekly_meetings', + sa.Column('recurrence', sa.String(), nullable=True) + ) + op.add_column( + 'coding_challenges', + sa.Column('deadline', sa.TIMESTAMP(timezone=True), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column('weekly_meetings', 'scheduled_time') + op.drop_column('weekly_meetings', 'recurrence') + op.drop_column('coding_challenges', 'deadline') diff --git a/Alembic/versions/e89564e882b1_add_missing_tables.py b/Alembic/versions/e89564e882b1_add_missing_tables.py new file mode 100644 index 0000000..7896ae2 --- /dev/null +++ b/Alembic/versions/e89564e882b1_add_missing_tables.py @@ -0,0 +1,79 @@ +"""Add missing tables (email_templates, weekly_meetings, coding_challenges) + +Revision ID: e89564e882b1 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-31 16:29:58.728294 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e89564e882b1' +down_revision = 'b2c3d4e5f6a7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### Create email_templates table ### + op.create_table('email_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_name', sa.String(), nullable=True), + sa.Column('html_content', sa.String(), nullable=True), + sa.Column('subject', sa.String(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False) + op.create_index(op.f('ix_email_templates_template_name'), 'email_templates', ['template_name'], unique=True) + + # ### Create weekly_meetings table ### + op.create_table('weekly_meetings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('meeting_url', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_weekly_meetings_id'), 'weekly_meetings', ['id'], unique=False) + + # ### Create coding_challenges table ### + op.create_table('coding_challenges', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('challenge_type', sa.Enum('LEETCODE', 'SYSTEM_DESIGN', 'GENERAL', name='challengetype'), nullable=False), + sa.Column('difficulty', sa.String(), nullable=True), + sa.Column('challenge_url', sa.String(), nullable=True), + sa.Column('posted_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_coding_challenges_id'), 'coding_challenges', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### Drop coding_challenges table ### + op.drop_index(op.f('ix_coding_challenges_id'), table_name='coding_challenges') + op.drop_table('coding_challenges') + op.execute('DROP TYPE IF EXISTS challengetype') + + # ### Drop weekly_meetings table ### + op.drop_index(op.f('ix_weekly_meetings_id'), table_name='weekly_meetings') + op.drop_table('weekly_meetings') + + # ### Drop email_templates table ### + op.drop_index(op.f('ix_email_templates_template_name'), table_name='email_templates') + op.drop_index(op.f('ix_email_templates_id'), table_name='email_templates') + op.drop_table('email_templates') + # ### end Alembic commands ### diff --git a/README.md b/README.md index 9539308..5b98892 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@

## πŸ“ Table of Contents -- [TODO](#todo) - [About](#about) - [Getting Started](#getting_started) - [Running the tests](#tests) @@ -27,11 +26,34 @@ - [Built Using](#built_using) - [Team](#team) -## Todo -See [TODO](./docs/TODO.md) ## About -This project is a CRM API for Slightly Techie. It is built using [these](#built_using) technologies. +The Slightly Techie CRM API is a comprehensive backend service for managing customer relationships, user profiles, projects, skills, announcements, and more. It provides RESTful endpoints for all CRM operations. and includes features like: + +Built with FastAPI for high performance and automatic API documentation. + +### Automatic Initialization on Startup + +When the application starts, it automatically initializes essential seed data: + +**Roles** +- Admin +- User +- Guest + +**Stacks (Technical Specializations)** +- Backend +- Frontend +- Fullstack +- Mobile +- UI/UX +- DevOps +- Data Science + +**Signup Endpoint** +- Created automatically for user registration tracking + +This ensures the application is ready to use without manual database setup for these core entities. ## 🏁 Getting Started These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. @@ -101,32 +123,49 @@ pip install #### Step 5: Create a `.env` file in the project's root directory and add the following environment variables, replacing the placeholders with your specific values: ```bash -POSTGRES_USER= #e.g postgres -POSTGRES_PASSWORD= #e.g password123 -POSTGRES_SERVER= #e.g localhost -POSTGRES_PORT= #e.g 5432 -POSTGRES_DB= #e.g st_crm_db -SECRET= #notasecretkey -BASE_URL= #http://127.0.0.1:8080/ +# Database Configuration +POSTGRES_USER=postgres # e.g., postgres +POSTGRES_PASSWORD=password123 # e.g., your_secure_password +POSTGRES_SERVER=localhost # e.g., localhost or your_db_host +POSTGRES_PORT=5432 # Default PostgreSQL port +POSTGRES_DB=st_crm_db # e.g., st_crm_db +POSTGRES_DB_TEST=st_crm_db_test # Test database + +# Security & Authentication +SECRET=your_secret_key_here # JWT secret key (use a strong value) +REFRESH_SECRET=your_refresh_key # JWT refresh secret key (use a strong value) + +# Server Configuration +BASE_URL=http://localhost:3000 # Frontend URL for CORS and links +URL_PATH=/users/reset-password/new-password + +# Email Configuration (Gmail SMTP) EMAIL_SENDER=someawesomeemail@gmail.com -EMAIL_PASSWORD=xxxxxxxxxxx +EMAIL_PASSWORD=your_app_password # Use Gmail app-specific password EMAIL_SERVER=smtp.gmail.com EMAIL_PORT=587 -AWS_ACCESS_KEY=xxxxxxxxxxxxx -AWS_SECRET_KEY=xxxxxxxxxxx -AWS_BUCKET_NAME=stncrmutilities -AWS_REGION=eu-west-1 -URL_PATH=/users/reset-password/new-password + +# Cloudinary Configuration (Media Storage) +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret ``` #### Step 6: Create a `test.env` file in the root directory and add the following environment variables ```bash -POSTGRES_USER= #e.g postgres -POSTGRES_PASSWORD= #e.g password123 -POSTGRES_SERVER= #e.g localhost -POSTGRES_PORT= #e.g 5432 -POSTGRES_DB= #e.g st_crm_db +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=st_crm_db_test + +# Optional: Add these if running tests that involve media uploads +SECRET=test_secret_key +REFRESH_SECRET=test_refresh_secret +CLOUDINARY_CLOUD_NAME=your_test_cloud_name +CLOUDINARY_API_KEY=your_test_api_key +CLOUDINARY_API_SECRET=your_test_api_secret ``` #### Step 7: Start the uvicorn server @@ -135,7 +174,50 @@ POSTGRES_DB= #e.g st_crm_db uvicorn app:app --reload ``` -### Step 8: Interact with the Database +> **⚠️ Important - First Time Setup**: +> On the **first startup**, the application automatically initializes seed data: +> - **Roles**: Admin, User, Guest +> - **Stacks**: Backend, Frontend, Fullstack, Mobile, UI/UX, DevOps, Data Science +> - **Signup Endpoint**: Required for user registration +> +> This is handled by the `startup_event()` in `app.py` which is enabled via: +> ```python +> app.add_event_handler("startup", startup_event) +> ``` +> +> If this event handler is commented out and you need to re-initialize the database, you can: +> - Uncomment `app.add_event_handler("startup", startup_event)` in `app.py`, OR +> - Run manually in Python: +> ```python +> from db.database import create_roles, create_stacks +> create_roles() +> create_stacks() +> ``` + +### Step 8: (Optional) Populate Test Data and Update Users + +To create 20 dummy users for testing: + +```bash +python create_dummy_users.py +``` + +This creates test users with credentials: +- Email: `user1@slightlytechie.com` β†’ `user20@slightlytechie.com` +- Password: `TestPassword123!` (for all) +- Users are distributed across different stacks + +**Additional user management scripts** (if needed): + +```bash +# Update all users' status from TO_CONTACT to ACCEPTED +python update_users_status.py + +# Update all users' role_id to 2 (User role) +python update_users_role.py +``` + +### Step 9: Interact with the Database To interact with the database, you can use tools like psql, pgAdmin or any database client that supports PostgreSQL. Here are some basic commands: - Connect to the database: @@ -239,9 +321,6 @@ pytest β”‚ β”‚ users.py β”‚ └───repository β”‚ users.py -β”œβ”€β”€β”€docs -β”‚ TODO.md -β”‚ β”œβ”€β”€β”€test β”‚ β”‚ conftest.py β”‚ β”‚ test_announcements.py @@ -299,13 +378,14 @@ visit the API Documentation at [https://crm-api.fly.dev/docs](https://crm-api.fl ## ⛏️ Built Using - [FastAPI](https://fastapi.tiangolo.com/) - Python Framework -- [Postgres](https://www.postgresql.org/) - Database -- [Fly.io](https://fly.io/) - Cloud Hosting -- [Poetry](https://python-poetry.org/) - Python Package Manager -- [Docker](https://www.docker.com/) - Containerization -- [SqlAlchemy](https://www.sqlalchemy.org/) - ORM -- [Alembic](https://alembic.sqlalchemy.org/en/latest/) - Database Migration +- [PostgreSQL](https://www.postgresql.org/) - Relational Database +- [SQLAlchemy](https://www.sqlalchemy.org/) - ORM (Object-Relational Mapping) +- [Alembic](https://alembic.sqlalchemy.org/en/latest/) - Database Migration Tool +- [Cloudinary](https://cloudinary.com/) - Media Storage & CDN - [Pytest](https://docs.pytest.org/en/6.2.x/) - Testing Framework +- [Poetry](https://python-poetry.org/) - Python Dependency Manager +- [Docker](https://www.docker.com/) - Containerization +- [Fly.io](https://fly.io/) - Cloud Hosting Platform ## ✍️ Team - [@RansfordGenesis](https://github.com/RansfordGenesis) diff --git a/api/api_models/announcements.py b/api/api_models/announcements.py index 039b976..b7c5351 100644 --- a/api/api_models/announcements.py +++ b/api/api_models/announcements.py @@ -23,5 +23,6 @@ class AnnouncementResponse(AnnouncementBase): class AnnouncementUpdate(BaseModel): title: Optional[str] content: Optional[str] + image_url: Optional[str] = None model_config = ConfigDict(from_attributes=True) diff --git a/api/api_models/coding_challenges.py b/api/api_models/coding_challenges.py new file mode 100644 index 0000000..980054a --- /dev/null +++ b/api/api_models/coding_challenges.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + + +class CodingChallengeBase(BaseModel): + title: str = Field(..., min_length=1, max_length=300) + description: str = Field(..., min_length=1) + challenge_type: str = Field(..., pattern="^(LEETCODE|SYSTEM_DESIGN|GENERAL)$") + difficulty: Optional[str] = Field(None, pattern="^(Easy|Medium|Hard)$") + challenge_url: Optional[str] = None + deadline: Optional[datetime] = None + + +class CodingChallengeCreate(CodingChallengeBase): + pass + + +class CodingChallengeUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=300) + description: Optional[str] = Field(None, min_length=1) + challenge_type: Optional[str] = Field(None, pattern="^(LEETCODE|SYSTEM_DESIGN|GENERAL)$") + difficulty: Optional[str] = Field(None, pattern="^(Easy|Medium|Hard)$") + challenge_url: Optional[str] = None + deadline: Optional[datetime] = None + + +class CodingChallengeResponse(CodingChallengeBase): + id: int + posted_at: datetime + created_by: int + + class Config: + from_attributes = True diff --git a/api/api_models/weekly_meetings.py b/api/api_models/weekly_meetings.py new file mode 100644 index 0000000..015fdd8 --- /dev/null +++ b/api/api_models/weekly_meetings.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + + +class WeeklyMeetingBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + meeting_url: str = Field(..., min_length=1) + description: Optional[str] = None + scheduled_time: Optional[datetime] = None + recurrence: Optional[str] = Field(None, pattern="^(weekly|biweekly|monthly|none)$") + + +class WeeklyMeetingCreate(WeeklyMeetingBase): + pass + + +class WeeklyMeetingUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + meeting_url: Optional[str] = Field(None, min_length=1) + description: Optional[str] = None + is_active: Optional[bool] = None + scheduled_time: Optional[datetime] = None + recurrence: Optional[str] = Field(None, pattern="^(weekly|biweekly|monthly|none)$") + + +class WeeklyMeetingResponse(WeeklyMeetingBase): + id: int + is_active: bool + created_at: datetime + updated_at: datetime + created_by: int + + class Config: + from_attributes = True diff --git a/api/routes/announcements.py b/api/routes/announcements.py index 81986e8..a830c6c 100644 --- a/api/routes/announcements.py +++ b/api/routes/announcements.py @@ -1,3 +1,5 @@ +from typing import Annotated, Any + from fastapi import APIRouter, Depends, status from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.links import Page @@ -11,25 +13,31 @@ announcement_route = APIRouter(tags=["Announcements"], prefix="/announcements") +DBSession = Annotated[Session, Depends(get_db)] +AdminUser = Annotated[Any, Depends(is_admin)] + def _service(db: Session) -> AnnouncementService: return AnnouncementService(AnnouncementRepository(db)) @announcement_route.post("/", status_code=status.HTTP_201_CREATED, response_model=AnnouncementResponse) -def create_announcement(announcement: AnnouncementCreate, current_user=Depends(is_admin), - db: Session = Depends(get_db)): +def create_announcement( + announcement: AnnouncementCreate, + current_user: AdminUser, + db: DBSession, +): return _service(db).create(current_user.id, announcement.model_dump()) @announcement_route.get("/", status_code=status.HTTP_200_OK, response_model=Page[AnnouncementResponse]) -def get_announcements(db: Session = Depends(get_db)): +def get_announcements(db: DBSession): return paginate(db, _service(db).get_all_query()) @announcement_route.get("/{announcement_id}", status_code=status.HTTP_200_OK, response_model=AnnouncementResponse) -def get_announcement_by_id(announcement_id: int, db: Session = Depends(get_db)): +def get_announcement_by_id(announcement_id: int, db: DBSession): return _service(db).get_by_id(announcement_id) @@ -37,14 +45,15 @@ def get_announcement_by_id(announcement_id: int, db: Session = Depends(get_db)): response_model=AnnouncementResponse) def update_announcement_by_id( announcement_id: int, announcement: AnnouncementUpdate, - current_user=Depends(is_admin), db: Session = Depends(get_db) + current_user: AdminUser, db: DBSession ): - return _service(db).update(announcement_id, announcement.model_dump()) + return _service(db).update(announcement_id, announcement.model_dump(exclude_unset=True)) @announcement_route.delete("/{announcement_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_announcement_by_id( - announcement_id: int, db: Session = Depends(get_db), - current_user=Depends(is_admin) + announcement_id: int, + db: DBSession, + current_user: AdminUser, ): _service(db).delete(announcement_id) diff --git a/api/routes/coding_challenges.py b/api/routes/coding_challenges.py new file mode 100644 index 0000000..fc1d8a2 --- /dev/null +++ b/api/routes/coding_challenges.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, status +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page +from sqlalchemy.orm import Session + +from api.api_models.coding_challenges import ( + CodingChallengeCreate, + CodingChallengeResponse, + CodingChallengeUpdate +) +from db.database import get_db +from db.repository.coding_challenges import CodingChallengeRepository +from services.coding_challenge_service import CodingChallengeService +from utils.permissions import is_admin +from utils.oauth2 import get_current_user + +coding_challenge_route = APIRouter(tags=["Coding Challenges"], prefix="/coding-challenges") + + +def _service(db: Session) -> CodingChallengeService: + return CodingChallengeService(CodingChallengeRepository(db)) + + +@coding_challenge_route.post("/", status_code=status.HTTP_201_CREATED, response_model=CodingChallengeResponse) +def create_challenge( + challenge: CodingChallengeCreate, + current_user=Depends(is_admin), + db: Session = Depends(get_db) +): + """Create a new coding challenge - Admin only""" + return _service(db).create(challenge.model_dump(), current_user.id) + + +@coding_challenge_route.get("/latest", status_code=status.HTTP_200_OK, response_model=CodingChallengeResponse | None) +def get_latest_challenge(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get the most recent coding challenge - All users""" + return _service(db).get_latest() + + +@coding_challenge_route.get("/", status_code=status.HTTP_200_OK, response_model=Page[CodingChallengeResponse]) +def get_all_challenges(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get all coding challenges - All users""" + return paginate(db, _service(db).get_all_query()) + + +@coding_challenge_route.get("/{challenge_id}", status_code=status.HTTP_200_OK, response_model=CodingChallengeResponse) +def get_challenge_by_id( + challenge_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get a specific challenge by ID""" + return _service(db).get_by_id(challenge_id) + + +@coding_challenge_route.put("/{challenge_id}", status_code=status.HTTP_200_OK, response_model=CodingChallengeResponse) +def update_challenge( + challenge_id: int, + challenge: CodingChallengeUpdate, + current_user=Depends(is_admin), + db: Session = Depends(get_db) +): + """Update a challenge - Admin only""" + return _service(db).update(challenge_id, challenge.model_dump(exclude_unset=True)) + + +@coding_challenge_route.delete("/{challenge_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_challenge( + challenge_id: int, + db: Session = Depends(get_db), + current_user=Depends(is_admin) +): + """Delete a challenge - Admin only""" + _service(db).delete(challenge_id) diff --git a/api/routes/profile_page.py b/api/routes/profile_page.py index e7b35b3..9b0a6b3 100644 --- a/api/routes/profile_page.py +++ b/api/routes/profile_page.py @@ -1,12 +1,24 @@ -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, File, Query, UploadFile, status from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.links import Page +from pydantic import BaseModel from sqlalchemy.orm import Session from api.api_models.user import ApplicantProfileResponse, ProfileResponse, ProfileUpdate from utils.enums import UserStatus + + +class BatchStatusUpdateRequest(BaseModel): + user_ids: List[int] + status: UserStatus + + +class BatchStatusUpdateResponse(BaseModel): + updated_count: int + updated_users: List[ProfileResponse] + failed_ids: List[int] from db.database import get_db from db.models.users import User from db.repository.email_templates import EmailTemplateRepository @@ -71,3 +83,29 @@ async def update_user_status(user_id: int, new_status: UserStatus, db: Session = async def update_avi(current_user: User = Depends(get_current_user), db: Session = Depends(get_db), file: UploadFile = File(...)): return await _service(db).update_avatar(current_user, file) + + +@profile_route.post("/batch/status", response_model=BatchStatusUpdateResponse, + status_code=status.HTTP_200_OK) +async def batch_update_status( + request: BatchStatusUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(is_admin) +): + """Batch update user statuses - Admin only""" + updated_users = [] + failed_ids = [] + + for user_id in request.user_ids: + try: + updated_user = await _service(db).update_user_status(user_id, request.status) + updated_users.append(updated_user) + except Exception as e: + print(f"Failed to update user {user_id}: {str(e)}") + failed_ids.append(user_id) + + return BatchStatusUpdateResponse( + updated_count=len(updated_users), + updated_users=updated_users, + failed_ids=failed_ids + ) diff --git a/api/routes/skills.py b/api/routes/skills.py index 9a01046..ddedf90 100644 --- a/api/routes/skills.py +++ b/api/routes/skills.py @@ -1,9 +1,10 @@ -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, Query, status from fastapi.params import Body from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.links import Page +from pydantic import BaseModel from sqlalchemy.orm import Session from api.api_models.user import Skills @@ -11,6 +12,12 @@ from db.repository.skills import SkillRepository from services.skill_service import SkillService from utils.oauth2 import get_current_user +from utils.permissions import is_admin + + +class SkillCreate(BaseModel): + name: str + image_url: Optional[str] = None skill_route = APIRouter(tags=["Skills"], prefix="/skills") @@ -50,3 +57,14 @@ def populate_skills(db: Session = Depends(get_db)): def search_skills(name: str = Query(..., min_length=1, max_length=50), db: Session = Depends(get_db)): return _service(db).search_skills(name) + + +# Admin: manage the shared skills pool +@skill_route.post("/pool", response_model=Skills, status_code=status.HTTP_201_CREATED) +def create_skill_in_pool(body: SkillCreate, _admin=Depends(is_admin), db: Session = Depends(get_db)): + return _service(db).create_pool_skill(body.name, body.image_url) + + +@skill_route.delete("/pool/{skill_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_skill_from_pool(skill_id: int, _admin=Depends(is_admin), db: Session = Depends(get_db)): + _service(db).delete_pool_skill(skill_id) diff --git a/api/routes/users.py b/api/routes/users.py index a4bd98c..9890011 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -3,13 +3,13 @@ **Admin-only** (full visibility): GET /users/org-chart – complete organisational tree - GET /users/{user_id}/subordinates – direct reports of any user GET /users/{user_id}/org-chart – full subtree rooted at any user PATCH /users/{user_id}/manager – assign / remove a user's manager -**Authenticated accepted users** (self-scoped): +**Authenticated accepted users**: GET /users/me/manager – my manager GET /users/me/subordinates – my direct reports + GET /users/{user_id}/subordinates – direct reports of any user (visible to all) """ from __future__ import annotations @@ -111,7 +111,7 @@ def bulk_assign_subordinates( ) def get_subordinates( user_id: int, - current_user=Depends(is_admin), + current_user=Depends(user_accepted), service: OrgChartService = Depends(_get_service), ): return service.get_direct_subordinates(user_id) diff --git a/api/routes/weekly_meetings.py b/api/routes/weekly_meetings.py new file mode 100644 index 0000000..fcc0d68 --- /dev/null +++ b/api/routes/weekly_meetings.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, status +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page +from sqlalchemy.orm import Session + +from api.api_models.weekly_meetings import ( + WeeklyMeetingCreate, + WeeklyMeetingResponse, + WeeklyMeetingUpdate +) +from db.database import get_db +from db.repository.weekly_meetings import WeeklyMeetingRepository +from services.weekly_meeting_service import WeeklyMeetingService +from utils.permissions import is_admin +from utils.oauth2 import get_current_user + +weekly_meeting_route = APIRouter(tags=["Weekly Meetings"], prefix="/weekly-meetings") + + +def _service(db: Session) -> WeeklyMeetingService: + return WeeklyMeetingService(WeeklyMeetingRepository(db)) + + +@weekly_meeting_route.post("/", status_code=status.HTTP_201_CREATED, response_model=WeeklyMeetingResponse) +def create_meeting( + meeting: WeeklyMeetingCreate, + current_user=Depends(is_admin), + db: Session = Depends(get_db) +): + """Create a new weekly meeting - Admin only""" + return _service(db).create(meeting.model_dump(), current_user.id) + + +@weekly_meeting_route.get("/active", status_code=status.HTTP_200_OK, response_model=WeeklyMeetingResponse | None) +def get_active_meeting(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get the current active meeting - All users""" + return _service(db).get_active() + + +@weekly_meeting_route.get("/", status_code=status.HTTP_200_OK, response_model=Page[WeeklyMeetingResponse]) +def get_all_meetings(db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get all meetings - All users""" + return paginate(db, _service(db).get_all_query()) + + +@weekly_meeting_route.get("/{meeting_id}", status_code=status.HTTP_200_OK, response_model=WeeklyMeetingResponse) +def get_meeting_by_id( + meeting_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get a specific meeting by ID""" + return _service(db).get_by_id(meeting_id) + + +@weekly_meeting_route.put("/{meeting_id}", status_code=status.HTTP_200_OK, response_model=WeeklyMeetingResponse) +def update_meeting( + meeting_id: int, + meeting: WeeklyMeetingUpdate, + current_user=Depends(is_admin), + db: Session = Depends(get_db) +): + """Update a meeting - Admin only""" + return _service(db).update(meeting_id, meeting.model_dump(exclude_unset=True)) + + +@weekly_meeting_route.delete("/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_meeting( + meeting_id: int, + db: Session = Depends(get_db), + current_user=Depends(is_admin) +): + """Delete a meeting - Admin only""" + _service(db).delete(meeting_id) diff --git a/app.py b/app.py index 9401d75..9124985 100644 --- a/app.py +++ b/app.py @@ -6,11 +6,13 @@ from api.routes.feeds import feed_route from api.routes.techieotm import techieotm_router from api.routes.announcements import announcement_route +from api.routes.weekly_meetings import weekly_meeting_route +from api.routes.coding_challenges import coding_challenge_route # from db.database import engine # from db.database import Base from fastapi.middleware.cors import CORSMiddleware -from db.database import create_roles +from db.database import create_roles, create_stacks, SessionLocal from api.routes.tags import tag_route from api.routes.stacks import stack_router from api.routes.project import project_router @@ -20,6 +22,8 @@ from api.routes.users import users_route from api.routes.migrate_media import migrate_media_route from utils.endpoints_status import create_signup_endpoint +from db.models.users import User +from sqlalchemy import func # Base.metadata.create_all(bind=engine) @@ -27,8 +31,13 @@ app = FastAPI() origins = [ - "http://localhost", "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:8000", + "https://20d9-154-161-108-106.ngrok-free.app", + "https://unalacritous-glory-bedfast.ngrok-free.dev", + "http://127.0.0.1:4040", "https://crm-web.fly.dev", "https://app.slightlytechie.com", ] @@ -55,14 +64,38 @@ def redirect(): } # noqa: E501 +@app.get("/api/v1/health") +def health_check(): + """Health check endpoint - accessible without authentication""" + try: + db = SessionLocal() + # Try to count users to verify database connection + user_count = db.query(func.count(User.id)).scalar() + db.close() + return { + "status": "healthy", + "database": "connected", + "user_count": user_count, + "message": "API is running and connected to database" + } + except Exception as e: + return { + "status": "unhealthy", + "database": "disconnected", + "error": str(e), + "message": "Database connection failed" + }, 500 + + async def startup_event(): create_roles() + create_stacks() create_signup_endpoint() v1_prefix = "/api/v1" -# app.add_event_handler("startup", startup_event) +app.add_event_handler("startup", startup_event) app.include_router(auth_router, prefix=v1_prefix) app.include_router(profile_route, prefix=v1_prefix) app.include_router(skill_route, prefix=v1_prefix) @@ -78,6 +111,8 @@ async def startup_event(): app.include_router(endpoints_route, prefix=v1_prefix) app.include_router(users_route, prefix=v1_prefix) app.include_router(migrate_media_route, prefix=v1_prefix) +app.include_router(weekly_meeting_route, prefix=v1_prefix) +app.include_router(coding_challenge_route, prefix=v1_prefix) add_pagination(app) diff --git a/db/__init__.py b/db/__init__.py index 864a62c..bf106da 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -14,3 +14,6 @@ from .models.endpoints import Endpoints from .models.project_stacks import ProjectStack from .models.project_skills import ProjectSkill +from .models.email_template import EmailTemplate +from .models.weekly_meetings import WeeklyMeeting +from .models.coding_challenges import CodingChallenge diff --git a/db/models/coding_challenges.py b/db/models/coding_challenges.py new file mode 100644 index 0000000..e160d91 --- /dev/null +++ b/db/models/coding_challenges.py @@ -0,0 +1,27 @@ +from db.database import Base +from sqlalchemy import Column, String, Integer, TIMESTAMP, text, Enum as SQLEnum +from sqlalchemy.orm import relationship +from sqlalchemy import ForeignKey +import enum + + +class ChallengeType(str, enum.Enum): + LEETCODE = "LEETCODE" + SYSTEM_DESIGN = "SYSTEM_DESIGN" + GENERAL = "GENERAL" + + +class CodingChallenge(Base): + __tablename__ = 'coding_challenges' + id = Column(Integer, primary_key=True, nullable=False) + title = Column(String, nullable=False) + description = Column(String, nullable=False) + challenge_type = Column(SQLEnum(ChallengeType), nullable=False, default=ChallengeType.LEETCODE) + difficulty = Column(String) # Easy, Medium, Hard + challenge_url = Column(String) # Link to LeetCode, etc. + posted_at = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")) + deadline = Column(TIMESTAMP(timezone=True), nullable=True) + created_by = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user = relationship("User") diff --git a/db/models/weekly_meetings.py b/db/models/weekly_meetings.py new file mode 100644 index 0000000..f61f0bc --- /dev/null +++ b/db/models/weekly_meetings.py @@ -0,0 +1,22 @@ +from db.database import Base +from sqlalchemy import Column, String, Integer, TIMESTAMP, text, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy import ForeignKey + + +class WeeklyMeeting(Base): + __tablename__ = 'weekly_meetings' + id = Column(Integer, primary_key=True, nullable=False) + title = Column(String, nullable=False) + meeting_url = Column(String, nullable=False) + description = Column(String) + is_active = Column(Boolean, server_default='true', nullable=False) + scheduled_time = Column(TIMESTAMP(timezone=True), nullable=True) + recurrence = Column(String, nullable=True) # weekly, biweekly, monthly, none + created_at = Column( + TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")) + updated_at = Column(TIMESTAMP(timezone=True), + nullable=False, server_default=text('now()')) + created_by = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user = relationship("User") diff --git a/db/repository/coding_challenges.py b/db/repository/coding_challenges.py new file mode 100644 index 0000000..6c8cc1a --- /dev/null +++ b/db/repository/coding_challenges.py @@ -0,0 +1,48 @@ +from sqlalchemy.orm import Session +from db.models.coding_challenges import CodingChallenge + + +class CodingChallengeRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, data: dict, user_id: int) -> CodingChallenge: + challenge = CodingChallenge(**data, created_by=user_id) + self.db.add(challenge) + self.db.commit() + self.db.refresh(challenge) + return challenge + + def get_by_id(self, challenge_id: int) -> CodingChallenge | None: + return self.db.query(CodingChallenge).filter(CodingChallenge.id == challenge_id).first() + + def get_latest(self) -> CodingChallenge | None: + """Get the most recent coding challenge""" + return ( + self.db.query(CodingChallenge) + .order_by(CodingChallenge.posted_at.desc()) + .first() + ) + + def get_all_query(self): + return self.db.query(CodingChallenge).order_by(CodingChallenge.posted_at.desc()) + + def update(self, challenge_id: int, data: dict) -> CodingChallenge: + challenge = self.get_by_id(challenge_id) + if not challenge: + raise ValueError(f"Challenge with id {challenge_id} not found") + + for key, value in data.items(): + setattr(challenge, key, value) + + self.db.commit() + self.db.refresh(challenge) + return challenge + + def delete(self, challenge_id: int) -> None: + challenge = self.get_by_id(challenge_id) + if not challenge: + raise ValueError(f"Challenge with id {challenge_id} not found") + + self.db.delete(challenge) + self.db.commit() diff --git a/db/repository/org_chart.py b/db/repository/org_chart.py index 411eb5c..8676259 100644 --- a/db/repository/org_chart.py +++ b/db/repository/org_chart.py @@ -5,6 +5,7 @@ from db.models.users import User from db.repository.base import BaseRepository +from utils.enums import UserStatus class OrgChartRepository(BaseRepository): @@ -16,7 +17,7 @@ def get_user(self, user_id: int) -> Optional[User]: def get_direct_subordinates(self, user_id: int) -> list[User]: return ( self.db.query(User) - .filter(User.manager_id == user_id) + .filter(User.manager_id == user_id, User.status == UserStatus.ACCEPTED) .all() ) @@ -25,18 +26,18 @@ def get_subtree_ids(self, root_id: int, max_depth: int = 5) -> list[dict]: Returns a list of dicts: [{"id": ..., "manager_id": ..., "depth": ...}] """ - # Anchor: the root user at depth 0 + # Anchor: the root user at depth 0 (only accepted users) anchor = ( select( User.id, User.manager_id, literal(0).label("depth"), ) - .where(User.id == root_id) + .where(User.id == root_id, User.status == UserStatus.ACCEPTED) .cte(name="org_tree", recursive=True) ) - # Recursive member: children of current level, depth + 1 + # Recursive member: children of current level, depth + 1 (only accepted) recursive = ( select( User.id, @@ -44,7 +45,7 @@ def get_subtree_ids(self, root_id: int, max_depth: int = 5) -> list[dict]: (anchor.c.depth + 1).label("depth"), ) .join(anchor, User.manager_id == anchor.c.id) - .where(anchor.c.depth < max_depth) + .where(anchor.c.depth < max_depth, User.status == UserStatus.ACCEPTED) ) cte = anchor.union_all(recursive) @@ -59,10 +60,10 @@ def get_users_by_ids(self, user_ids: list[int]) -> list[User]: return self.db.query(User).filter(User.id.in_(user_ids)).all() def get_root_users(self) -> list[User]: - """Return users with no manager (org tree roots).""" + """Return accepted users with no manager (org tree roots).""" return ( self.db.query(User) - .filter(User.manager_id.is_(None)) + .filter(User.manager_id.is_(None), User.status == UserStatus.ACCEPTED) .all() ) diff --git a/db/repository/skills.py b/db/repository/skills.py index 597e065..783a18a 100644 --- a/db/repository/skills.py +++ b/db/repository/skills.py @@ -60,3 +60,19 @@ def upsert(self, name: str, image_url: Optional[str]) -> Skill: skill = Skill(name=name, image_url=image_url) self.db.add(skill) return skill + + def create(self, name: str, image_url: Optional[str]) -> Skill: + skill = Skill(name=name, image_url=image_url) + self.db.add(skill) + self.db.commit() + self.db.refresh(skill) + return skill + + def delete_from_pool(self, skill_id: int) -> None: + skill = self.db.query(Skill).filter(Skill.id == skill_id).first() + if skill: + self.db.delete(skill) + self.db.commit() + + def get_by_id(self, skill_id: int) -> Optional[Skill]: + return self.db.query(Skill).filter(Skill.id == skill_id).first() diff --git a/db/repository/weekly_meetings.py b/db/repository/weekly_meetings.py new file mode 100644 index 0000000..b3800f8 --- /dev/null +++ b/db/repository/weekly_meetings.py @@ -0,0 +1,49 @@ +from sqlalchemy.orm import Session +from db.models.weekly_meetings import WeeklyMeeting + + +class WeeklyMeetingRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, data: dict, user_id: int) -> WeeklyMeeting: + meeting = WeeklyMeeting(**data, created_by=user_id) + self.db.add(meeting) + self.db.commit() + self.db.refresh(meeting) + return meeting + + def get_by_id(self, meeting_id: int) -> WeeklyMeeting | None: + return self.db.query(WeeklyMeeting).filter(WeeklyMeeting.id == meeting_id).first() + + def get_active(self) -> WeeklyMeeting | None: + """Get the most recent active meeting""" + return ( + self.db.query(WeeklyMeeting) + .filter(WeeklyMeeting.is_active == True) + .order_by(WeeklyMeeting.created_at.desc()) + .first() + ) + + def get_all_query(self): + return self.db.query(WeeklyMeeting).order_by(WeeklyMeeting.created_at.desc()) + + def update(self, meeting_id: int, data: dict) -> WeeklyMeeting: + meeting = self.get_by_id(meeting_id) + if not meeting: + raise ValueError(f"Meeting with id {meeting_id} not found") + + for key, value in data.items(): + setattr(meeting, key, value) + + self.db.commit() + self.db.refresh(meeting) + return meeting + + def delete(self, meeting_id: int) -> None: + meeting = self.get_by_id(meeting_id) + if not meeting: + raise ValueError(f"Meeting with id {meeting_id} not found") + + self.db.delete(meeting) + self.db.commit() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3184a31..e8486a5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -46,10 +46,9 @@ services: - EMAIL_PASSWORD=${EMAIL_PASSWORD} - EMAIL_SERVER=${EMAIL_SERVER} - EMAIL_PORT=${EMAIL_PORT} - - AWS_ACCESS_KEY=${AWS_ACCESS_KEY} - - AWS_SECRET_KEY=${AWS_SECRET_KEY} - - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} - - AWS_REGION=${AWS_REGION} + - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME} + - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY} + - CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET} - URL_PATH=${URL_PATH} volumes: diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index e69de29..0000000 diff --git a/services/auth_service.py b/services/auth_service.py index 8551f8c..16f396b 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -1,5 +1,6 @@ import logging from fastapi import HTTPException, status +from sqlalchemy.exc import ProgrammingError from api.api_models.user import Token, UserSignUp from core.config import settings from db.models.users import User @@ -109,13 +110,36 @@ def refresh(self, refresh_token: str) -> Token: ) async def forgot_password(self, email: str) -> dict: - email = email.lower() - user = self.user_repo.get_by_email(email) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - reset_token = create_reset_token(email) - email_template = self.email_template_repo.get_by_name("PASSWORD RESET") - return await send_password_reset_email(email, reset_token, user.username, email_template) + try: + email = email.lower() + user = self.user_repo.get_by_email(email) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + reset_token = create_reset_token(email) + try: + email_template = self.email_template_repo.get_by_name("PASSWORD RESET") + except ProgrammingError: + # Fallback when migrations haven't created the email_templates table yet. + email_template = None + response = await send_password_reset_email(email, reset_token, user.username, email_template) + # If response is JSONResponse, extract the content + if hasattr(response, 'body'): + import json + content = json.loads(response.body) + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=content.get("message", "Failed to send password reset email") + ) + return {"message": "Password reset email sent successfully"} + except HTTPException: + raise + except Exception as e: + logger.error("Forgot password failed: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process password reset request. Please check your email address and try again." + ) def reset_password(self, token: str, new_password: str) -> dict: try: diff --git a/services/coding_challenge_service.py b/services/coding_challenge_service.py new file mode 100644 index 0000000..4400fd2 --- /dev/null +++ b/services/coding_challenge_service.py @@ -0,0 +1,25 @@ +from db.repository.coding_challenges import CodingChallengeRepository + + +class CodingChallengeService: + def __init__(self, repository: CodingChallengeRepository): + self.repository = repository + + def create(self, data: dict, user_id: int): + return self.repository.create(data, user_id) + + def get_by_id(self, challenge_id: int): + return self.repository.get_by_id(challenge_id) + + def get_latest(self): + """Get the most recent coding challenge""" + return self.repository.get_latest() + + def get_all_query(self): + return self.repository.get_all_query() + + def update(self, challenge_id: int, data: dict): + return self.repository.update(challenge_id, data) + + def delete(self, challenge_id: int): + return self.repository.delete(challenge_id) diff --git a/services/skill_service.py b/services/skill_service.py index 63d7529..c79d646 100644 --- a/services/skill_service.py +++ b/services/skill_service.py @@ -69,3 +69,21 @@ def search_skills(self, name: str) -> list[dict]: for skill in skills if fuzz.partial_ratio(name.lower(), skill.name.lower()) >= threshold ] + + def create_pool_skill(self, name: str, image_url=None): + existing = self.skill_repo.get_by_name(name) + if existing: + raise HTTPException( + status_code=400, + detail=f"Skill '{name}' already exists" + ) + return self.skill_repo.create(name, image_url) + + def delete_pool_skill(self, skill_id: int) -> None: + skill = self.skill_repo.get_by_id(skill_id) + if not skill: + raise HTTPException( + status_code=404, + detail=f"Skill with id {skill_id} not found" + ) + self.skill_repo.delete_from_pool(skill_id) diff --git a/services/user_service.py b/services/user_service.py index 11fad9e..4ed8a70 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -2,6 +2,7 @@ from typing import Optional from fastapi import HTTPException, UploadFile, status +from sqlalchemy.exc import ProgrammingError from core.config import settings from db.models.users import User @@ -77,7 +78,12 @@ async def update_user_status(self, user_id: int, new_status: UserStatus) -> User self.user_repo.update_status(user, new_status) if new_status in (UserStatus.ACCEPTED, UserStatus.REJECTED): - template = self.email_template_repo.get_by_name(new_status.value) + try: + template = self.email_template_repo.get_by_name(new_status.value) + except ProgrammingError: + # Keep status changes working even if the email_templates migration has not run yet. + self.email_template_repo.db.rollback() + template = None if template: html_content = template.html_content.format(html.escape(user.username)) await send_email(template.subject, user.email, html_content) diff --git a/services/weekly_meeting_service.py b/services/weekly_meeting_service.py new file mode 100644 index 0000000..6f40410 --- /dev/null +++ b/services/weekly_meeting_service.py @@ -0,0 +1,25 @@ +from db.repository.weekly_meetings import WeeklyMeetingRepository + + +class WeeklyMeetingService: + def __init__(self, repository: WeeklyMeetingRepository): + self.repository = repository + + def create(self, data: dict, user_id: int): + return self.repository.create(data, user_id) + + def get_by_id(self, meeting_id: int): + return self.repository.get_by_id(meeting_id) + + def get_active(self): + """Get the currently active meeting""" + return self.repository.get_active() + + def get_all_query(self): + return self.repository.get_all_query() + + def update(self, meeting_id: int, data: dict): + return self.repository.update(meeting_id, data) + + def delete(self, meeting_id: int): + return self.repository.delete(meeting_id) diff --git a/update_users_role.py b/update_users_role.py new file mode 100644 index 0000000..e841bb4 --- /dev/null +++ b/update_users_role.py @@ -0,0 +1,55 @@ +"""Script to update all users' role_id to 2""" +from db.database import SessionLocal +from db.models.users import User +from db.models.roles import Role + + +def update_users_role(): + """Update all users' role_id to 2""" + db = SessionLocal() + + try: + # First, check what role_id 2 is + role_2 = db.query(Role).filter(Role.id == 2).first() + if role_2: + print(f"Role ID 2 is: {role_2.name}") + else: + print("Role ID 2 not found!") + return + + # Get all users + all_users = db.query(User).all() + print(f"\nFound {len(all_users)} users to update") + + if len(all_users) == 0: + print("No users to update!") + return + + # Update all users to role_id 2 + for user in all_users: + old_role_id = user.role_id + user.role_id = 2 + print(f"βœ“ Updated {user.email}: role_id {old_role_id} β†’ 2") + + db.commit() + print(f"\nβœ… Successfully updated {len(all_users)} users to role_id 2!") + + # Display the updated users + updated_users = db.query(User).all() + + print(f"\nTotal users updated: {len(updated_users)}") + print(f"All users now have role_id: 2 ({role_2.name})") + for user in updated_users[:5]: + print(f" - {user.email} - role_id: {user.role_id}") + if len(updated_users) > 5: + print(f" ... and {len(updated_users) - 5} more") + + except Exception as e: + db.rollback() + print(f"❌ Error updating users: {str(e)}") + finally: + db.close() + + +if __name__ == "__main__": + update_users_role() diff --git a/update_users_status.py b/update_users_status.py new file mode 100644 index 0000000..8b21809 --- /dev/null +++ b/update_users_status.py @@ -0,0 +1,48 @@ +"""Script to update all users' status from TO_CONTACT to ACCEPTED""" +from db.database import SessionLocal +from db.models.users import User +from utils.enums import UserStatus + + +def update_users_status(): + """Update all users' status from TO_CONTACT to ACCEPTED""" + db = SessionLocal() + + try: + # Get all users with TO_CONTACT status + users_to_update = db.query(User).filter( + User.status == UserStatus.TO_CONTACT + ).all() + + print(f"Found {len(users_to_update)} users with TO_CONTACT status") + + if len(users_to_update) == 0: + print("No users to update!") + return + + # Update all to ACCEPTED status + for user in users_to_update: + user.status = UserStatus.ACCEPTED + print(f"βœ“ Updated {user.email}: {user.status}") + + db.commit() + print(f"\nβœ… Successfully updated {len(users_to_update)} users to ACCEPTED status!") + + # Display the updated users + updated_users = db.query(User).filter( + User.status == UserStatus.ACCEPTED + ).all() + + print(f"\nTotal users with ACCEPTED status: {len(updated_users)}") + for user in updated_users: + print(f" - {user.email} ({user.first_name} {user.last_name}) - Status: {user.status}") + + except Exception as e: + db.rollback() + print(f"❌ Error updating users: {str(e)}") + finally: + db.close() + + +if __name__ == "__main__": + update_users_status() diff --git a/utils/mail_service.py b/utils/mail_service.py index fa0f940..d99f678 100644 --- a/utils/mail_service.py +++ b/utils/mail_service.py @@ -17,11 +17,12 @@ def read_html_file(file_path): async def send_email(subject: str, recipient_email: str, html_content: str) -> JSONResponse: """ - Generic email sending function. + Generic email sending function supporting both SMTP (port 587 with STARTTLS) and SMTP_SSL (port 465). """ email_sender = settings.EMAIL_SENDER email_password = settings.EMAIL_PASSWORD email_receiver = recipient_email + email_port = int(settings.EMAIL_PORT) em = MIMEMultipart() em['From'] = email_sender @@ -33,12 +34,27 @@ async def send_email(subject: str, recipient_email: str, html_content: str) -> J context = ssl.create_default_context() try: - with smtplib.SMTP_SSL(settings.EMAIL_SERVER, settings.EMAIL_PORT, context=context) as smtp: - smtp.login(email_sender, email_password) - smtp.sendmail(email_sender, email_receiver, em.as_string()) + # Port 587 uses STARTTLS, port 465 uses SMTP_SSL + if email_port == 465: + with smtplib.SMTP_SSL(settings.EMAIL_SERVER, email_port, context=context) as smtp: + smtp.login(email_sender, email_password) + smtp.sendmail(email_sender, email_receiver, em.as_string()) + else: # Port 587 or other non-SSL ports + with smtplib.SMTP(settings.EMAIL_SERVER, email_port, timeout=10) as smtp: + smtp.starttls(context=context) + smtp.login(email_sender, email_password) + smtp.sendmail(email_sender, email_receiver, em.as_string()) + return JSONResponse(status_code=200, content={"message": "Email sent successfully"}) + except smtplib.SMTPAuthenticationError as e: + print(f"SMTP Authentication Error: {e}") + return JSONResponse(status_code=500, content={"message": "Email authentication failed. Check EMAIL_PASSWORD and EMAIL_SENDER."}) + except smtplib.SMTPException as e: + print(f"SMTP Error: {e}") + return JSONResponse(status_code=500, content={"message": f"Email server error: {str(e)}"}) except Exception as e: - return JSONResponse(status_code=500, content={"message": f"An error occurred: {e}"}) + print(f"Unexpected error sending email: {e}") + return JSONResponse(status_code=500, content={"message": f"An error occurred: {str(e)}"}) async def send_password_reset_email(email: str, reset_token: str, username: str, email_template): From a6b7d88dbefa43b9aede80ca36addc6d0d2a7e4d Mon Sep 17 00:00:00 2001 From: jbkyei1 Date: Tue, 31 Mar 2026 23:50:49 +0000 Subject: [PATCH 2/4] fix: update database and email configuration in .env.sample --- .env.sample | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/.env.sample b/.env.sample index e8daf85..0609d06 100644 --- a/.env.sample +++ b/.env.sample @@ -1,29 +1,17 @@ -# Database Configuration POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres -POSTGRES_DB=st_crm_db -POSTGRES_DB_TEST=st_crm_db_test +POSTGRES_DB=github_actions_db POSTGRES_PORT=5432 POSTGRES_SERVER=localhost - -# Security & Authentication -SECRET=your_secret_key_here -REFRESH_SECRET=your_refresh_secret_here - -# Server Configuration BASE_URL=http://localhost:3000 -URL_PATH=/users/reset-password/new-password - -# Email Configuration -EMAIL_SENDER=your_email@gmail.com -EMAIL_PASSWORD=your_app_password +EMAIL_SENDER=someawesomeemail@gmail.com +EMAIL_PASSWORD=xxxxxxxxxxx EMAIL_SERVER=smtp.gmail.com EMAIL_PORT=587 - -# Cloudinary Configuration (for media uploads) -CLOUDINARY_CLOUD_NAME=your_cloud_name -CLOUDINARY_API_KEY=your_api_key -CLOUDINARY_API_SECRET=your_api_secret - -# Production flag (set to "true" in production environments) -PRODUCTION_ENV=false +URL_PATH=/users/reset-password/new-password +SECRET=RMi7R08iX02B8s85QXmXvCK7 +REFRESH_SECRET=2B8s85QXmXvCK7RMi7R08iX0 +EMAIL_PASSWORD=5ioVUFj4KdOF1aR15iLS9HvZ +CLOUDINARY_CLOUD_NAME=Xl9q9TVz88jF8gxrb9XudOX2 +CLOUDINARY_API_KEY=Jfu39X7wCmS05aBd2oJb9j3c +CLOUDINARY_API_SECRET=7W8b0ksC84cQq9n97tXqu8gW \ No newline at end of file From a580ed02378ece31637a6113fff2c87a91626157 Mon Sep 17 00:00:00 2001 From: jbkyei1 Date: Wed, 1 Apr 2026 00:45:52 +0000 Subject: [PATCH 3/4] add tests for coding challenges and weekly meetings endpoints --- api/api_models/coding_challenges.py | 6 +- api/api_models/weekly_meetings.py | 6 +- api/routes/migrate_media.py | 169 ---------------------------- api/routes/users.py | 67 ++++++++++- app.py | 9 +- db/database.py | 3 +- db/repository/org_chart.py | 15 ++- test/test_coding_challenges.py | 108 ++++++++++++++++++ test/test_org_chart.py | 44 ++++++++ test/test_profile_page.py | 54 +++++++++ test/test_skills.py | 44 ++++++++ test/test_weekly_meetings.py | 101 +++++++++++++++++ utils/oauth2.py | 4 +- utils/tools.py | 9 +- 14 files changed, 438 insertions(+), 201 deletions(-) delete mode 100644 api/routes/migrate_media.py create mode 100644 test/test_coding_challenges.py create mode 100644 test/test_weekly_meetings.py diff --git a/api/api_models/coding_challenges.py b/api/api_models/coding_challenges.py index 980054a..7e0fc40 100644 --- a/api/api_models/coding_challenges.py +++ b/api/api_models/coding_challenges.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional @@ -29,6 +29,4 @@ class CodingChallengeResponse(CodingChallengeBase): id: int posted_at: datetime created_by: int - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/api/api_models/weekly_meetings.py b/api/api_models/weekly_meetings.py index 015fdd8..dbba665 100644 --- a/api/api_models/weekly_meetings.py +++ b/api/api_models/weekly_meetings.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional @@ -30,6 +30,4 @@ class WeeklyMeetingResponse(WeeklyMeetingBase): created_at: datetime updated_at: datetime created_by: int - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/api/routes/migrate_media.py b/api/routes/migrate_media.py deleted file mode 100644 index d85e0c2..0000000 --- a/api/routes/migrate_media.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -One-time migration route to replace S3 URLs with Cloudinary URLs in the database. - -Reads the mapping from scripts/url_mapping.json (generated by scripts/upload_to_cloudinary.py) -and updates all image/file URL columns that contain S3 references. - -This route should be called once after deployment, then removed. -""" - -import json -import logging -from pathlib import Path - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from db.database import get_db -from db.models.announcements import Announcement -from db.models.feeds import Feed -from db.models.skills import Skill -from db.models.users import User -from utils.permissions import is_admin - -logger = logging.getLogger(__name__) - -migrate_media_route = APIRouter(tags=["Migration"], prefix="/migrate") - -# S3 URL pattern to match β€” covers both virtual-hosted and path-style URLs -S3_MARKERS = (".s3.amazonaws.com", ".s3.us-east", ".s3.eu-west", ".s3.af-south") - -# Tables and their URL columns to scan -URL_COLUMNS = [ - (User, ["profile_pic_url"]), - (Feed, ["feed_pic_url"]), - (Skill, ["image_url"]), - (Announcement, ["image_url"]), -] - -MAPPING_FILE = Path(__file__).resolve().parent.parent.parent / "scripts" / "url_mapping.json" - - -def _load_mapping() -> dict[str, str]: - """Load the S3-path β†’ Cloudinary-URL mapping from disk.""" - if not MAPPING_FILE.exists(): - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Mapping file not found at {MAPPING_FILE}. Run scripts/upload_to_cloudinary.py first.", - ) - with open(MAPPING_FILE) as f: - mapping = json.load(f) - if not mapping: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Mapping file is empty. Run scripts/upload_to_cloudinary.py first.", - ) - return mapping - - -def _is_s3_url(url: str | None) -> bool: - if not url: - return False - return any(marker in url for marker in S3_MARKERS) - - -def _find_cloudinary_url(s3_url: str, mapping: dict[str, str]) -> str | None: - """Try to match an S3 URL to a Cloudinary URL via the mapping. - - The mapping keys are relative file paths (e.g. "slightlytechie/profile/20240101-12-00-00"). - The S3 URL looks like "https://.s3.amazonaws.com/". - We extract the path portion and look it up. - """ - for marker in S3_MARKERS: - if marker in s3_url: - # Split on the marker + any trailing region suffix + "/" - idx = s3_url.find(marker) - # Find the first "/" after the marker - path_start = s3_url.find("/", idx) - if path_start == -1: - continue - s3_path = s3_url[path_start + 1:] # strip leading "/" - # Try exact match - if s3_path in mapping: - return mapping[s3_path] - # Try without query string - clean_path = s3_path.split("?")[0] - if clean_path in mapping: - return mapping[clean_path] - return None - - -@migrate_media_route.post( - "/s3-to-cloudinary", - summary="Replace S3 URLs with Cloudinary URLs in the database", -) -def migrate_s3_to_cloudinary( - dry_run: bool = True, - db: Session = Depends(get_db), - current_user=Depends(is_admin), -): - """Scan all URL columns for S3 references and replace them with Cloudinary URLs. - - - **dry_run=true** (default): Report what would change without modifying the DB. - - **dry_run=false**: Apply the changes and commit. - """ - mapping = _load_mapping() - - results = { - "scanned": 0, - "s3_urls_found": 0, - "replaced": 0, - "not_in_mapping": [], - "changes": [], - } - - for model, columns in URL_COLUMNS: - table_name = model.__tablename__ - rows = db.query(model).all() - - for row in rows: - for col in columns: - results["scanned"] += 1 - old_url = getattr(row, col, None) - - if not _is_s3_url(old_url): - continue - - results["s3_urls_found"] += 1 - new_url = _find_cloudinary_url(old_url, mapping) - - if new_url is None: - results["not_in_mapping"].append({ - "table": table_name, - "id": row.id, - "column": col, - "s3_url": old_url, - }) - continue - - change = { - "table": table_name, - "id": row.id, - "column": col, - "old_url": old_url, - "new_url": new_url, - } - results["changes"].append(change) - - if not dry_run: - setattr(row, col, new_url) - results["replaced"] += 1 - - if not dry_run and results["replaced"] > 0: - db.commit() - logger.info("Migrated %d S3 URLs to Cloudinary", results["replaced"]) - - results["dry_run"] = dry_run - if dry_run: - results["message"] = ( - f"Dry run complete. {len(results['changes'])} URL(s) would be replaced. " - f"{len(results['not_in_mapping'])} S3 URL(s) have no mapping. " - f"Call with dry_run=false to apply." - ) - else: - results["message"] = ( - f"Migration complete. {results['replaced']} URL(s) replaced. " - f"{len(results['not_in_mapping'])} S3 URL(s) had no mapping and were skipped." - ) - - return results diff --git a/api/routes/users.py b/api/routes/users.py index 9890011..124f2d8 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -3,13 +3,17 @@ **Admin-only** (full visibility): GET /users/org-chart – complete organisational tree - GET /users/{user_id}/org-chart – full subtree rooted at any user + GET /users/{user_id}/manager – manager of any user + GET /users/{user_id}/subordinates – direct reports of any user + GET /users/{user_id}/org-chart – full subtree rooted at any user PATCH /users/{user_id}/manager – assign / remove a user's manager **Authenticated accepted users**: GET /users/me/manager – my manager GET /users/me/subordinates – my direct reports - GET /users/{user_id}/subordinates – direct reports of any user (visible to all) + GET /users/view/{user_id}/manager – manager of any user + GET /users/view/{user_id}/subordinates – direct reports of any user + GET /users/view/{user_id}/org-chart – full subtree rooted at any user """ from __future__ import annotations @@ -100,10 +104,67 @@ def bulk_assign_subordinates( return service.bulk_assign_subordinates(manager_id, payload.user_ids) +# --------------------------------------------------------------------------- +# Accepted-user read-only view endpoints +# --------------------------------------------------------------------------- + +@users_route.get( + "/view/{user_id}/manager", + response_model=Optional[ManagerInfo], + summary="View manager of a user", +) +def view_user_manager( + user_id: int, + current_user=Depends(user_accepted), + service: OrgChartService = Depends(_get_service), +): + return service.get_manager(user_id) + + +@users_route.get( + "/view/{user_id}/subordinates", + response_model=list[SubordinateResponse], + summary="View direct reports of a user", +) +def view_subordinates( + user_id: int, + current_user=Depends(user_accepted), + service: OrgChartService = Depends(_get_service), +): + return service.get_direct_subordinates(user_id) + + +@users_route.get( + "/view/{user_id}/org-chart", + response_model=OrgChartNode, + summary="View full reporting subtree rooted at a user", +) +def view_user_org_chart( + user_id: int, + max_depth: int = Query(default=5, ge=1, le=20), + current_user=Depends(user_accepted), + service: OrgChartService = Depends(_get_service), +): + return service.get_subtree(user_id, max_depth) + + # --------------------------------------------------------------------------- # Admin endpoints β€” parameterised # --------------------------------------------------------------------------- +@users_route.get( + "/{user_id}/manager", + response_model=Optional[ManagerInfo], + summary="Get manager of a user", +) +def get_user_manager( + user_id: int, + current_user=Depends(is_admin), + service: OrgChartService = Depends(_get_service), +): + return service.get_manager(user_id) + + @users_route.get( "/{user_id}/subordinates", response_model=list[SubordinateResponse], @@ -111,7 +172,7 @@ def bulk_assign_subordinates( ) def get_subordinates( user_id: int, - current_user=Depends(user_accepted), + current_user=Depends(is_admin), service: OrgChartService = Depends(_get_service), ): return service.get_direct_subordinates(user_id) diff --git a/app.py b/app.py index 9124985..0338207 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,6 @@ from fastapi_pagination import add_pagination from api.routes.endpoints import endpoints_route from api.routes.users import users_route -from api.routes.migrate_media import migrate_media_route from utils.endpoints_status import create_signup_endpoint from db.models.users import User from sqlalchemy import func @@ -31,13 +30,8 @@ app = FastAPI() origins = [ + "http://localhost", "http://localhost:3000", - "http://localhost:3001", - "http://localhost:3002", - "http://localhost:8000", - "https://20d9-154-161-108-106.ngrok-free.app", - "https://unalacritous-glory-bedfast.ngrok-free.dev", - "http://127.0.0.1:4040", "https://crm-web.fly.dev", "https://app.slightlytechie.com", ] @@ -110,7 +104,6 @@ async def startup_event(): app.include_router(email_templates_route, prefix=v1_prefix) app.include_router(endpoints_route, prefix=v1_prefix) app.include_router(users_route, prefix=v1_prefix) -app.include_router(migrate_media_route, prefix=v1_prefix) app.include_router(weekly_meeting_route, prefix=v1_prefix) app.include_router(coding_challenge_route, prefix=v1_prefix) diff --git a/db/database.py b/db/database.py index 406708b..3830a77 100644 --- a/db/database.py +++ b/db/database.py @@ -1,6 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base, sessionmaker from core.config import settings pg_user = settings.POSTGRES_USER diff --git a/db/repository/org_chart.py b/db/repository/org_chart.py index 8676259..411eb5c 100644 --- a/db/repository/org_chart.py +++ b/db/repository/org_chart.py @@ -5,7 +5,6 @@ from db.models.users import User from db.repository.base import BaseRepository -from utils.enums import UserStatus class OrgChartRepository(BaseRepository): @@ -17,7 +16,7 @@ def get_user(self, user_id: int) -> Optional[User]: def get_direct_subordinates(self, user_id: int) -> list[User]: return ( self.db.query(User) - .filter(User.manager_id == user_id, User.status == UserStatus.ACCEPTED) + .filter(User.manager_id == user_id) .all() ) @@ -26,18 +25,18 @@ def get_subtree_ids(self, root_id: int, max_depth: int = 5) -> list[dict]: Returns a list of dicts: [{"id": ..., "manager_id": ..., "depth": ...}] """ - # Anchor: the root user at depth 0 (only accepted users) + # Anchor: the root user at depth 0 anchor = ( select( User.id, User.manager_id, literal(0).label("depth"), ) - .where(User.id == root_id, User.status == UserStatus.ACCEPTED) + .where(User.id == root_id) .cte(name="org_tree", recursive=True) ) - # Recursive member: children of current level, depth + 1 (only accepted) + # Recursive member: children of current level, depth + 1 recursive = ( select( User.id, @@ -45,7 +44,7 @@ def get_subtree_ids(self, root_id: int, max_depth: int = 5) -> list[dict]: (anchor.c.depth + 1).label("depth"), ) .join(anchor, User.manager_id == anchor.c.id) - .where(anchor.c.depth < max_depth, User.status == UserStatus.ACCEPTED) + .where(anchor.c.depth < max_depth) ) cte = anchor.union_all(recursive) @@ -60,10 +59,10 @@ def get_users_by_ids(self, user_ids: list[int]) -> list[User]: return self.db.query(User).filter(User.id.in_(user_ids)).all() def get_root_users(self) -> list[User]: - """Return accepted users with no manager (org tree roots).""" + """Return users with no manager (org tree roots).""" return ( self.db.query(User) - .filter(User.manager_id.is_(None), User.status == UserStatus.ACCEPTED) + .filter(User.manager_id.is_(None)) .all() ) diff --git a/test/test_coding_challenges.py b/test/test_coding_challenges.py new file mode 100644 index 0000000..73f5cd6 --- /dev/null +++ b/test/test_coding_challenges.py @@ -0,0 +1,108 @@ +def _auth_header(client, email: str, password: str) -> dict: + res = client.post("/api/v1/users/login", data={"username": email, "password": password}) + assert res.status_code == 200, res.text + token = res.json()["token"] + return {"Authorization": f"Bearer {token}"} + + +def _challenge_payload(title: str = "Two Sum") -> dict: + return { + "title": title, + "description": "Find two numbers that add up to target", + "challenge_type": "LEETCODE", + "difficulty": "Easy", + "challenge_url": "https://leetcode.com/problems/two-sum/", + } + + +def test_admin_can_create_challenge(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + res = client.post("/api/v1/coding-challenges/", json=_challenge_payload(), headers=headers) + assert res.status_code == 201 + assert res.json()["title"] == "Two Sum" + + +def test_non_admin_cannot_create_challenge(client, test_user1): + headers = _auth_header(client, test_user1["email"], test_user1["password"]) + res = client.post("/api/v1/coding-challenges/", json=_challenge_payload(), headers=headers) + assert res.status_code == 403 + + +def test_authenticated_user_can_get_challenges(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + create_res = client.post( + "/api/v1/coding-challenges/", + json=_challenge_payload("Valid Parentheses"), + headers=admin_headers, + ) + assert create_res.status_code == 201 + + list_res = client.get("/api/v1/coding-challenges/", headers=user_headers) + assert list_res.status_code == 200 + assert len(list_res.json()["items"]) >= 1 + + +def test_get_latest_challenge(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + first = client.post( + "/api/v1/coding-challenges/", + json=_challenge_payload("First Challenge"), + headers=admin_headers, + ) + assert first.status_code == 201 + + second = client.post( + "/api/v1/coding-challenges/", + json={**_challenge_payload("Second Challenge"), "difficulty": "Medium"}, + headers=admin_headers, + ) + assert second.status_code == 201 + + latest_res = client.get("/api/v1/coding-challenges/latest", headers=user_headers) + assert latest_res.status_code == 200 + assert latest_res.json()["title"] == "Second Challenge" + + +def test_admin_can_update_challenge(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + + create_res = client.post("/api/v1/coding-challenges/", json=_challenge_payload(), headers=headers) + challenge_id = create_res.json()["id"] + + update_res = client.put( + f"/api/v1/coding-challenges/{challenge_id}", + json={"title": "Updated Challenge", "difficulty": "Hard"}, + headers=headers, + ) + assert update_res.status_code == 200 + assert update_res.json()["title"] == "Updated Challenge" + assert update_res.json()["difficulty"] == "Hard" + + +def test_non_admin_cannot_update_challenge(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + create_res = client.post("/api/v1/coding-challenges/", json=_challenge_payload(), headers=admin_headers) + challenge_id = create_res.json()["id"] + + update_res = client.put( + f"/api/v1/coding-challenges/{challenge_id}", + json={"title": "Should Not Update"}, + headers=user_headers, + ) + assert update_res.status_code == 403 + + +def test_admin_can_delete_challenge(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + + create_res = client.post("/api/v1/coding-challenges/", json=_challenge_payload(), headers=headers) + challenge_id = create_res.json()["id"] + + delete_res = client.delete(f"/api/v1/coding-challenges/{challenge_id}", headers=headers) + assert delete_res.status_code == 204 diff --git a/test/test_org_chart.py b/test/test_org_chart.py index adb1ace..9ccefa4 100644 --- a/test/test_org_chart.py +++ b/test/test_org_chart.py @@ -289,6 +289,50 @@ def test_forbidden_for_non_accepted_user(self, client, user_headers): assert res.status_code == 403 +# =========================================================================== +# ACCEPTED-USER VIEW ENDPOINTS +# =========================================================================== + + +class TestAcceptedUserViewEndpoints: + """/api/v1/users/view/{user_id}/... endpoints (accepted users)""" + + def test_view_user_manager(self, client, org_tree, accepted_user_headers, session): + _make_admin_accepted(session, org_tree["child"]) + res = client.get( + f"/api/v1/users/view/{org_tree['child']['id']}/manager", + headers=accepted_user_headers, + ) + assert res.status_code == 200 + assert res.json()["id"] == org_tree["root"]["id"] + + def test_view_user_subordinates(self, client, org_tree, accepted_user_headers, session): + _make_admin_accepted(session, org_tree["child"]) + res = client.get( + f"/api/v1/users/view/{org_tree['root']['id']}/subordinates", + headers=accepted_user_headers, + ) + assert res.status_code == 200 + sub_ids = {s["id"] for s in res.json()} + assert org_tree["child"]["id"] in sub_ids + + def test_view_user_org_chart(self, client, org_tree, accepted_user_headers, session): + _make_admin_accepted(session, org_tree["child"]) + res = client.get( + f"/api/v1/users/view/{org_tree['root']['id']}/org-chart", + headers=accepted_user_headers, + ) + assert res.status_code == 200 + assert res.json()["id"] == org_tree["root"]["id"] + + def test_view_endpoints_forbidden_for_non_accepted(self, client, org_tree, user_headers): + res = client.get( + f"/api/v1/users/view/{org_tree['root']['id']}/manager", + headers=user_headers, + ) + assert res.status_code == 403 + + # =========================================================================== # BULK ASSIGN SUBORDINATES # =========================================================================== diff --git a/test/test_profile_page.py b/test/test_profile_page.py index 3c9274c..b83e975 100644 --- a/test/test_profile_page.py +++ b/test/test_profile_page.py @@ -189,3 +189,57 @@ def test_search_user_not_found(client, test_users, user_cred): assert response.status_code == 200 assert len(response.json()["items"]) == 0 + + +def test_batch_update_status_admin_success(client, test_user, test_user1, inactive_user): + login_res = client.post( + "/api/v1/users/login", + data={"username": test_user["email"], "password": test_user["password"]}, + ) + token = login_res.json()["token"] + + res = client.post( + "/api/v1/users/batch/status", + json={"user_ids": [test_user1["id"], inactive_user["id"]], "status": "ACCEPTED"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert res.status_code == 200 + body = res.json() + assert body["updated_count"] == 2 + assert body["failed_ids"] == [] + + +def test_batch_update_status_admin_with_invalid_ids(client, test_user, test_user1): + login_res = client.post( + "/api/v1/users/login", + data={"username": test_user["email"], "password": test_user["password"]}, + ) + token = login_res.json()["token"] + + res = client.post( + "/api/v1/users/batch/status", + json={"user_ids": [test_user1["id"], 99999], "status": "ACCEPTED"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert res.status_code == 200 + body = res.json() + assert body["updated_count"] == 1 + assert 99999 in body["failed_ids"] + + +def test_batch_update_status_forbidden_for_non_admin(client, test_user1, inactive_user): + login_res = client.post( + "/api/v1/users/login", + data={"username": test_user1["email"], "password": test_user1["password"]}, + ) + token = login_res.json()["token"] + + res = client.post( + "/api/v1/users/batch/status", + json={"user_ids": [inactive_user["id"]], "status": "ACCEPTED"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert res.status_code == 403 diff --git a/test/test_skills.py b/test/test_skills.py index 85153af..b1f3c6b 100644 --- a/test/test_skills.py +++ b/test/test_skills.py @@ -83,3 +83,47 @@ def test_unauthorized_delete_skill(client, test_user, populate_skills): res = client.delete("/api/v1/skills/50") assert res.status_code == 401 + + +def test_admin_can_create_skill_in_pool(client, test_user): + login_res = client.post("/api/v1/users/login", data={"username": test_user["email"], "password": test_user["password"]}) + token = login_res.json()["token"] + + res = client.post( + "/api/v1/skills/pool", + json={"name": "SuperNewSkill", "image_url": "https://example.com/s.png"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert res.status_code == 201 + assert res.json()["name"] == "SuperNewSkill" + + +def test_non_admin_cannot_create_skill_in_pool(client, test_user1): + login_res = client.post("/api/v1/users/login", data={"username": test_user1["email"], "password": test_user1["password"]}) + token = login_res.json()["token"] + + res = client.post( + "/api/v1/skills/pool", + json={"name": "AnotherNewSkill"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert res.status_code == 403 + + +def test_admin_can_delete_skill_from_pool(client, test_user): + login_res = client.post("/api/v1/users/login", data={"username": test_user["email"], "password": test_user["password"]}) + token = login_res.json()["token"] + headers = {"Authorization": f"Bearer {token}"} + + create_res = client.post( + "/api/v1/skills/pool", + json={"name": "DeleteMeSkill"}, + headers=headers, + ) + assert create_res.status_code == 201 + + skill_id = create_res.json()["id"] + delete_res = client.delete(f"/api/v1/skills/pool/{skill_id}", headers=headers) + assert delete_res.status_code == 204 diff --git a/test/test_weekly_meetings.py b/test/test_weekly_meetings.py new file mode 100644 index 0000000..de5a1cc --- /dev/null +++ b/test/test_weekly_meetings.py @@ -0,0 +1,101 @@ +def _auth_header(client, email: str, password: str) -> dict: + res = client.post("/api/v1/users/login", data={"username": email, "password": password}) + assert res.status_code == 200, res.text + token = res.json()["token"] + return {"Authorization": f"Bearer {token}"} + + +def _meeting_payload(title: str = "Weekly Sync") -> dict: + return { + "title": title, + "meeting_url": "https://meet.google.com/abc-defg-hij", + "description": "Weekly team sync", + "recurrence": "weekly", + } + + +def test_admin_can_create_meeting(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + res = client.post("/api/v1/weekly-meetings/", json=_meeting_payload(), headers=headers) + assert res.status_code == 201 + assert res.json()["title"] == "Weekly Sync" + + +def test_non_admin_cannot_create_meeting(client, test_user1): + headers = _auth_header(client, test_user1["email"], test_user1["password"]) + res = client.post("/api/v1/weekly-meetings/", json=_meeting_payload(), headers=headers) + assert res.status_code == 403 + + +def test_authenticated_user_can_get_meetings(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + create_res = client.post( + "/api/v1/weekly-meetings/", + json=_meeting_payload("Engineering Standup"), + headers=admin_headers, + ) + assert create_res.status_code == 201 + + list_res = client.get("/api/v1/weekly-meetings/", headers=user_headers) + assert list_res.status_code == 200 + assert len(list_res.json()["items"]) >= 1 + + +def test_get_active_meeting(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + create_res = client.post( + "/api/v1/weekly-meetings/", + json=_meeting_payload("Active Meeting"), + headers=admin_headers, + ) + assert create_res.status_code == 201 + + active_res = client.get("/api/v1/weekly-meetings/active", headers=user_headers) + assert active_res.status_code == 200 + assert active_res.json()["title"] == "Active Meeting" + + +def test_admin_can_update_meeting(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + + create_res = client.post("/api/v1/weekly-meetings/", json=_meeting_payload(), headers=headers) + assert create_res.status_code == 201 + meeting_id = create_res.json()["id"] + + update_res = client.put( + f"/api/v1/weekly-meetings/{meeting_id}", + json={"title": "Updated Sync", "is_active": False}, + headers=headers, + ) + assert update_res.status_code == 200 + assert update_res.json()["title"] == "Updated Sync" + assert update_res.json()["is_active"] is False + + +def test_non_admin_cannot_update_meeting(client, test_user, test_user1): + admin_headers = _auth_header(client, test_user["email"], test_user["password"]) + user_headers = _auth_header(client, test_user1["email"], test_user1["password"]) + + create_res = client.post("/api/v1/weekly-meetings/", json=_meeting_payload(), headers=admin_headers) + meeting_id = create_res.json()["id"] + + update_res = client.put( + f"/api/v1/weekly-meetings/{meeting_id}", + json={"title": "Should Not Update"}, + headers=user_headers, + ) + assert update_res.status_code == 403 + + +def test_admin_can_delete_meeting(client, test_user): + headers = _auth_header(client, test_user["email"], test_user["password"]) + + create_res = client.post("/api/v1/weekly-meetings/", json=_meeting_payload(), headers=headers) + meeting_id = create_res.json()["id"] + + delete_res = client.delete(f"/api/v1/weekly-meetings/{meeting_id}", headers=headers) + assert delete_res.status_code == 204 diff --git a/utils/oauth2.py b/utils/oauth2.py index 8fab859..c751a78 100644 --- a/utils/oauth2.py +++ b/utils/oauth2.py @@ -21,7 +21,7 @@ def create_access_token(data: dict): to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.SECRET, algorithm=settings.ALGORITHM) return encoded_jwt @@ -29,7 +29,7 @@ def create_access_token(data: dict): def create_refresh_token(data: dict): to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) token = jwt.encode(to_encode, settings.REFRESH_SECRET, algorithm=settings.ALGORITHM) return token diff --git a/utils/tools.py b/utils/tools.py index d3405af..5249ec7 100644 --- a/utils/tools.py +++ b/utils/tools.py @@ -3,10 +3,14 @@ from datetime import datetime import requests -import cairosvg import cloudinary import cloudinary.uploader +try: + import cairosvg +except OSError: + cairosvg = None + from core.config import settings cloudinary.config( @@ -46,6 +50,9 @@ def get_icon(icon_name) -> str | None: def save_svg_as_png(svg_data, skill_name) -> str | None: + if cairosvg is None: + logger.warning("CairoSVG is unavailable; skipping svg->png conversion for skill '%s'", skill_name) + return None png_output = BytesIO() cairosvg.svg2png(bytestring=svg_data, write_to=png_output) png_output.seek(0) From 57370af9ae6dfdf45c2c0ca0c8650713b879cafe Mon Sep 17 00:00:00 2001 From: jbkyei1 Date: Wed, 1 Apr 2026 08:55:55 +0000 Subject: [PATCH 4/4] feat: Enhance models and services with improved error handling and new ChallengeType enum --- ...meetings_recurrence_challenges_deadline.py | 2 +- api/api_models/coding_challenges.py | 5 +- api/routes/profile_page.py | 6 +- app.py | 40 ++++++++------ db/models/coding_challenges.py | 8 +-- db/models/weekly_meetings.py | 2 +- db/repository/base.py | 3 + db/repository/weekly_meetings.py | 2 +- services/auth_service.py | 2 + update_users_role.py | 55 ------------------- update_users_status.py | 48 ---------------- utils/enums.py | 6 ++ utils/mail_service.py | 32 +++++++---- 13 files changed, 64 insertions(+), 147 deletions(-) delete mode 100644 update_users_role.py delete mode 100644 update_users_status.py diff --git a/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py b/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py index f3ba8ac..9b9581d 100644 --- a/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py +++ b/Alembic/versions/c1d2e3f4a5b6_meetings_recurrence_challenges_deadline.py @@ -1,7 +1,7 @@ """Add scheduled_time/recurrence to meetings, deadline to challenges Revision ID: c1d2e3f4a5b6 -Revises: b2c3d4e5f6a7 +Revises: e89564e882b1 Create Date: 2026-03-31 10:00:00.000000 """ diff --git a/api/api_models/coding_challenges.py b/api/api_models/coding_challenges.py index 7e0fc40..018651e 100644 --- a/api/api_models/coding_challenges.py +++ b/api/api_models/coding_challenges.py @@ -1,12 +1,13 @@ from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional +from utils.enums import ChallengeType class CodingChallengeBase(BaseModel): title: str = Field(..., min_length=1, max_length=300) description: str = Field(..., min_length=1) - challenge_type: str = Field(..., pattern="^(LEETCODE|SYSTEM_DESIGN|GENERAL)$") + challenge_type: ChallengeType difficulty: Optional[str] = Field(None, pattern="^(Easy|Medium|Hard)$") challenge_url: Optional[str] = None deadline: Optional[datetime] = None @@ -19,7 +20,7 @@ class CodingChallengeCreate(CodingChallengeBase): class CodingChallengeUpdate(BaseModel): title: Optional[str] = Field(None, min_length=1, max_length=300) description: Optional[str] = Field(None, min_length=1) - challenge_type: Optional[str] = Field(None, pattern="^(LEETCODE|SYSTEM_DESIGN|GENERAL)$") + challenge_type: Optional[ChallengeType] = None difficulty: Optional[str] = Field(None, pattern="^(Easy|Medium|Hard)$") challenge_url: Optional[str] = None deadline: Optional[datetime] = None diff --git a/api/routes/profile_page.py b/api/routes/profile_page.py index 9b0a6b3..7ae4db3 100644 --- a/api/routes/profile_page.py +++ b/api/routes/profile_page.py @@ -1,3 +1,4 @@ +import logging from typing import List, Optional from fastapi import APIRouter, Depends, File, Query, UploadFile, status @@ -29,6 +30,7 @@ class BatchStatusUpdateResponse(BaseModel): from utils.permissions import is_admin profile_route = APIRouter(tags=["User"], prefix="/users") +logger = logging.getLogger(__name__) def _service(db: Session) -> UserService: @@ -100,8 +102,8 @@ async def batch_update_status( try: updated_user = await _service(db).update_user_status(user_id, request.status) updated_users.append(updated_user) - except Exception as e: - print(f"Failed to update user {user_id}: {str(e)}") + except Exception: + logger.exception("Failed to update user status in batch", extra={"user_id": user_id}) failed_ids.append(user_id) return BatchStatusUpdateResponse( diff --git a/app.py b/app.py index 0338207..9a68e54 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ -from fastapi import FastAPI +import logging +from fastapi import FastAPI, HTTPException from api.routes.auth import auth_router from api.routes.email_templates import email_templates_route from api.routes.skills import skill_route @@ -21,12 +22,14 @@ from api.routes.endpoints import endpoints_route from api.routes.users import users_route from utils.endpoints_status import create_signup_endpoint -from db.models.users import User -from sqlalchemy import func +from sqlalchemy import text # Base.metadata.create_all(bind=engine) +logger = logging.getLogger(__name__) + + app = FastAPI() origins = [ @@ -58,27 +61,28 @@ def redirect(): } # noqa: E501 -@app.get("/api/v1/health") +@app.get( + "/api/v1/health", + responses={500: {"description": "Service health check failed"}} +) def health_check(): """Health check endpoint - accessible without authentication""" + db = SessionLocal() try: - db = SessionLocal() - # Try to count users to verify database connection - user_count = db.query(func.count(User.id)).scalar() - db.close() + # Lightweight DB liveness probe without exposing operational metrics. + db.execute(text("SELECT 1")) return { "status": "healthy", - "database": "connected", - "user_count": user_count, - "message": "API is running and connected to database" + "message": "Service is healthy" } - except Exception as e: - return { - "status": "unhealthy", - "database": "disconnected", - "error": str(e), - "message": "Database connection failed" - }, 500 + except Exception: + logger.exception("Health check failed") + raise HTTPException( + status_code=500, + detail={"status": "unhealthy", "message": "Service is unhealthy"} + ) + finally: + db.close() async def startup_event(): diff --git a/db/models/coding_challenges.py b/db/models/coding_challenges.py index e160d91..456c3a0 100644 --- a/db/models/coding_challenges.py +++ b/db/models/coding_challenges.py @@ -2,13 +2,7 @@ from sqlalchemy import Column, String, Integer, TIMESTAMP, text, Enum as SQLEnum from sqlalchemy.orm import relationship from sqlalchemy import ForeignKey -import enum - - -class ChallengeType(str, enum.Enum): - LEETCODE = "LEETCODE" - SYSTEM_DESIGN = "SYSTEM_DESIGN" - GENERAL = "GENERAL" +from utils.enums import ChallengeType class CodingChallenge(Base): diff --git a/db/models/weekly_meetings.py b/db/models/weekly_meetings.py index f61f0bc..a2132b7 100644 --- a/db/models/weekly_meetings.py +++ b/db/models/weekly_meetings.py @@ -16,7 +16,7 @@ class WeeklyMeeting(Base): created_at = Column( TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")) updated_at = Column(TIMESTAMP(timezone=True), - nullable=False, server_default=text('now()')) + nullable=False, server_default=text('now()'), onupdate=text('now()')) created_by = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user = relationship("User") diff --git a/db/repository/base.py b/db/repository/base.py index 4f87395..37a7785 100644 --- a/db/repository/base.py +++ b/db/repository/base.py @@ -23,5 +23,8 @@ def delete_obj(self, obj): def commit(self): self.db.commit() + def rollback(self): + self.db.rollback() + def refresh(self, obj): self.db.refresh(obj) diff --git a/db/repository/weekly_meetings.py b/db/repository/weekly_meetings.py index b3800f8..8669490 100644 --- a/db/repository/weekly_meetings.py +++ b/db/repository/weekly_meetings.py @@ -20,7 +20,7 @@ def get_active(self) -> WeeklyMeeting | None: """Get the most recent active meeting""" return ( self.db.query(WeeklyMeeting) - .filter(WeeklyMeeting.is_active == True) + .filter(WeeklyMeeting.is_active.is_(True)) .order_by(WeeklyMeeting.created_at.desc()) .first() ) diff --git a/services/auth_service.py b/services/auth_service.py index 16f396b..255db6f 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -120,6 +120,8 @@ async def forgot_password(self, email: str) -> dict: email_template = self.email_template_repo.get_by_name("PASSWORD RESET") except ProgrammingError: # Fallback when migrations haven't created the email_templates table yet. + # Also rollback the current DB transaction to avoid leaving the session in a failed state. + self.email_template_repo.rollback() email_template = None response = await send_password_reset_email(email, reset_token, user.username, email_template) # If response is JSONResponse, extract the content diff --git a/update_users_role.py b/update_users_role.py deleted file mode 100644 index e841bb4..0000000 --- a/update_users_role.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Script to update all users' role_id to 2""" -from db.database import SessionLocal -from db.models.users import User -from db.models.roles import Role - - -def update_users_role(): - """Update all users' role_id to 2""" - db = SessionLocal() - - try: - # First, check what role_id 2 is - role_2 = db.query(Role).filter(Role.id == 2).first() - if role_2: - print(f"Role ID 2 is: {role_2.name}") - else: - print("Role ID 2 not found!") - return - - # Get all users - all_users = db.query(User).all() - print(f"\nFound {len(all_users)} users to update") - - if len(all_users) == 0: - print("No users to update!") - return - - # Update all users to role_id 2 - for user in all_users: - old_role_id = user.role_id - user.role_id = 2 - print(f"βœ“ Updated {user.email}: role_id {old_role_id} β†’ 2") - - db.commit() - print(f"\nβœ… Successfully updated {len(all_users)} users to role_id 2!") - - # Display the updated users - updated_users = db.query(User).all() - - print(f"\nTotal users updated: {len(updated_users)}") - print(f"All users now have role_id: 2 ({role_2.name})") - for user in updated_users[:5]: - print(f" - {user.email} - role_id: {user.role_id}") - if len(updated_users) > 5: - print(f" ... and {len(updated_users) - 5} more") - - except Exception as e: - db.rollback() - print(f"❌ Error updating users: {str(e)}") - finally: - db.close() - - -if __name__ == "__main__": - update_users_role() diff --git a/update_users_status.py b/update_users_status.py deleted file mode 100644 index 8b21809..0000000 --- a/update_users_status.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Script to update all users' status from TO_CONTACT to ACCEPTED""" -from db.database import SessionLocal -from db.models.users import User -from utils.enums import UserStatus - - -def update_users_status(): - """Update all users' status from TO_CONTACT to ACCEPTED""" - db = SessionLocal() - - try: - # Get all users with TO_CONTACT status - users_to_update = db.query(User).filter( - User.status == UserStatus.TO_CONTACT - ).all() - - print(f"Found {len(users_to_update)} users with TO_CONTACT status") - - if len(users_to_update) == 0: - print("No users to update!") - return - - # Update all to ACCEPTED status - for user in users_to_update: - user.status = UserStatus.ACCEPTED - print(f"βœ“ Updated {user.email}: {user.status}") - - db.commit() - print(f"\nβœ… Successfully updated {len(users_to_update)} users to ACCEPTED status!") - - # Display the updated users - updated_users = db.query(User).filter( - User.status == UserStatus.ACCEPTED - ).all() - - print(f"\nTotal users with ACCEPTED status: {len(updated_users)}") - for user in updated_users: - print(f" - {user.email} ({user.first_name} {user.last_name}) - Status: {user.status}") - - except Exception as e: - db.rollback() - print(f"❌ Error updating users: {str(e)}") - finally: - db.close() - - -if __name__ == "__main__": - update_users_status() diff --git a/utils/enums.py b/utils/enums.py index 7417010..5302ca2 100644 --- a/utils/enums.py +++ b/utils/enums.py @@ -51,3 +51,9 @@ class ExperienceLevel(str, Enum): JUNIOR = "JUNIOR" MID_LEVEL = "MID LEVEL" SENIOR = "SENIOR" + + +class ChallengeType(str, Enum): + LEETCODE = "LEETCODE" + SYSTEM_DESIGN = "SYSTEM_DESIGN" + GENERAL = "GENERAL" diff --git a/utils/mail_service.py b/utils/mail_service.py index d99f678..c815ecb 100644 --- a/utils/mail_service.py +++ b/utils/mail_service.py @@ -1,5 +1,7 @@ +import logging import smtplib import ssl +import anyio from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from urllib.parse import urlencode @@ -10,6 +12,9 @@ # from db.models.email_template import EmailTemplate +logger = logging.getLogger(__name__) + + def read_html_file(file_path): with open(file_path, 'r') as file: return file.read() @@ -33,7 +38,7 @@ async def send_email(subject: str, recipient_email: str, html_content: str) -> J context = ssl.create_default_context() - try: + def _send_email_sync() -> None: # Port 587 uses STARTTLS, port 465 uses SMTP_SSL if email_port == 465: with smtplib.SMTP_SSL(settings.EMAIL_SERVER, email_port, context=context) as smtp: @@ -44,17 +49,20 @@ async def send_email(subject: str, recipient_email: str, html_content: str) -> J smtp.starttls(context=context) smtp.login(email_sender, email_password) smtp.sendmail(email_sender, email_receiver, em.as_string()) + + try: + await anyio.to_thread.run_sync(_send_email_sync) return JSONResponse(status_code=200, content={"message": "Email sent successfully"}) - except smtplib.SMTPAuthenticationError as e: - print(f"SMTP Authentication Error: {e}") - return JSONResponse(status_code=500, content={"message": "Email authentication failed. Check EMAIL_PASSWORD and EMAIL_SENDER."}) - except smtplib.SMTPException as e: - print(f"SMTP Error: {e}") - return JSONResponse(status_code=500, content={"message": f"Email server error: {str(e)}"}) - except Exception as e: - print(f"Unexpected error sending email: {e}") - return JSONResponse(status_code=500, content={"message": f"An error occurred: {str(e)}"}) + except smtplib.SMTPAuthenticationError: + logger.exception("SMTP authentication failed while sending email") + return JSONResponse(status_code=500, content={"message": "Unable to send email at the moment."}) + except smtplib.SMTPException: + logger.exception("SMTP error while sending email") + return JSONResponse(status_code=500, content={"message": "Unable to send email at the moment."}) + except Exception: + logger.exception("Unexpected error while sending email") + return JSONResponse(status_code=500, content={"message": "Unable to send email at the moment."}) async def send_password_reset_email(email: str, reset_token: str, username: str, email_template): @@ -86,8 +94,8 @@ async def send_applicant_task( try: html_content = read_html_file('utils/email_templates/task_template.html').format(first_name, task) except FileNotFoundError: - print("File not found") - html_content = f"Hello {first_name}, Kindly work on the this task and submit:\n {task}" + logger.warning("Task email template file not found; using fallback plain text") + html_content = f"Hello {first_name}, Kindly work on this task and submit:\n {task}" # html = task.format(first_name) # em = MIMEMultipart()