diff --git a/.env.sample b/.env.sample
index 7f9ac7d..0609d06 100644
--- a/.env.sample
+++ b/.env.sample
@@ -14,8 +14,4 @@ REFRESH_SECRET=2B8s85QXmXvCK7RMi7R08iX0
EMAIL_PASSWORD=5ioVUFj4KdOF1aR15iLS9HvZ
CLOUDINARY_CLOUD_NAME=Xl9q9TVz88jF8gxrb9XudOX2
CLOUDINARY_API_KEY=Jfu39X7wCmS05aBd2oJb9j3c
-CLOUDINARY_API_SECRET=7W8b0ksC84cQq9n97tXqu8gW
-
-
-
-
+CLOUDINARY_API_SECRET=7W8b0ksC84cQq9n97tXqu8gW
\ No newline at end of file
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..9b9581d
--- /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: e89564e882b1
+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..018651e
--- /dev/null
+++ b/api/api_models/coding_challenges.py
@@ -0,0 +1,33 @@
+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: ChallengeType
+ 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[ChallengeType] = None
+ 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
+ model_config = ConfigDict(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..dbba665
--- /dev/null
+++ b/api/api_models/weekly_meetings.py
@@ -0,0 +1,33 @@
+from pydantic import BaseModel, ConfigDict, 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
+ model_config = ConfigDict(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/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/profile_page.py b/api/routes/profile_page.py
index e7b35b3..7ae4db3 100644
--- a/api/routes/profile_page.py
+++ b/api/routes/profile_page.py
@@ -1,12 +1,25 @@
-from typing import Optional
+import logging
+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
@@ -17,6 +30,7 @@
from utils.permissions import is_admin
profile_route = APIRouter(tags=["User"], prefix="/users")
+logger = logging.getLogger(__name__)
def _service(db: Session) -> UserService:
@@ -71,3 +85,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:
+ logger.exception("Failed to update user status in batch", extra={"user_id": user_id})
+ 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..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}/subordinates β direct reports of any user
- 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** (self-scoped):
+**Authenticated accepted users**:
GET /users/me/manager β my manager
GET /users/me/subordinates β my direct reports
+ 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],
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..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
@@ -6,11 +7,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
@@ -18,12 +21,15 @@
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 sqlalchemy import text
# Base.metadata.create_all(bind=engine)
+logger = logging.getLogger(__name__)
+
+
app = FastAPI()
origins = [
@@ -55,14 +61,39 @@ def redirect():
} # noqa: E501
+@app.get(
+ "/api/v1/health",
+ responses={500: {"description": "Service health check failed"}}
+)
+def health_check():
+ """Health check endpoint - accessible without authentication"""
+ db = SessionLocal()
+ try:
+ # Lightweight DB liveness probe without exposing operational metrics.
+ db.execute(text("SELECT 1"))
+ return {
+ "status": "healthy",
+ "message": "Service is healthy"
+ }
+ 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():
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)
@@ -77,7 +108,8 @@ 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)
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/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/models/coding_challenges.py b/db/models/coding_challenges.py
new file mode 100644
index 0000000..456c3a0
--- /dev/null
+++ b/db/models/coding_challenges.py
@@ -0,0 +1,21 @@
+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
+from utils.enums import ChallengeType
+
+
+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..a2132b7
--- /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()'), 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/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/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..8669490
--- /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.is_(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..255db6f 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,38 @@ 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.
+ # 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
+ 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/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/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 fa0f940..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()
@@ -17,11 +22,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
@@ -32,13 +38,31 @@ async def send_email(subject: str, recipient_email: str, html_content: str) -> J
context = ssl.create_default_context()
+ 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:
+ 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())
+
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())
+ await anyio.to_thread.run_sync(_send_email_sync)
+
return JSONResponse(status_code=200, content={"message": "Email sent successfully"})
- except Exception as e:
- return JSONResponse(status_code=500, content={"message": f"An error occurred: {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):
@@ -70,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()
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)