diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d796157 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +# Description + + + +## Changes + +- +- + +## Related Issues + + + diff --git a/README.md b/README.md index f36e2b4..daa337a 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,473 @@ -# Guru Setu +# Guru Setu Backend API -## Abstract -Guru Setu is an intelligent web platform that bridges the gap between students and faculty in engineering colleges by leveraging machine learning-powered recommendations. The platform solves two critical problems: discovering cross-departmental research opportunities and locating faculty cabins on campus. Students receive personalized faculty recommendations based on matching interests and skills, while faculty get curated student suggestions for their research openings. The system eliminates information asymmetry, reduces time waste, and fosters meaningful academic collaborations beyond departmental boundaries. +## Overview -## Problem Statement +Guru Setu is a FastAPI-based backend platform that connects students and faculty in engineering colleges through ML-powered recommendations. Built on a Neo4j graph database, it enables intelligent matching based on skills, interests, research areas, and project experience. The system facilitates cross-departmental collaboration, research opportunity discovery, and streamlined application tracking. -### Discovery Gap -Students struggle to find research and project opportunities aligned with their interests, especially across departments. Faculty find it equally challenging to identify genuinely interested and capable students for their research work. +## Core Architecture -### Information Asymmetry -Current systems rely on informal networks, notice boards, and department-specific announcements, leading to missed collaboration opportunities and inefficient talent matching. +### Technology Stack -### Navigation Frustration -Students waste significant time wandering campus asking "Where is Prof. X's cabin?" even when they know whom to meet, creating a barrier to faculty-student interactions. +- **Framework**: FastAPI 0.109.0 +- **Database**: Neo4j 5.16 (Graph Database) +- **Authentication**: JWT tokens with bcrypt password hashing +- **ML/Embeddings**: Sentence Transformers (384-dim vectors for future semantic search) +- **Python Version**: 3.11+ -## Solution +### Graph Data Model -Guru Setu provides an ML-driven recommendation engine that matches students with faculty based on: +**Core Node Types**: -* Areas of interest and domain expertise -* Previous projects, research papers, and publications -* Current research and project openings -* Technical skills and technology stack overlap +- `User` (dual-labeled as `:User:Student` or `:User:Faculty`) +- `Opening` - Research/project opportunities posted by faculty +- `Concept` - Normalized skills, interests, and domains (lowercase) +- `Work` (dual-labeled as `:Work:Project` or `:Work:Publication`) +- `Notification` - System notifications for users -Additionally, the platform includes digital cabin navigation, displaying exact faculty locations (block, floor, cabin number) to eliminate search time and make campus visits effortless. +**Critical Relationships**: -## Key Features +```cypher +(Student)-[:HAS_SKILL]->(Concept) # Technical skills +(Student)-[:INTERESTED_IN]->(Concept) # Research interests +(Faculty)-[:INTERESTED_IN]->(Concept) # Faculty expertise +(Faculty)-[:POSTED]->(Opening) # Research openings +(Opening)-[:REQUIRES]->(Concept) # Required skills +(Student)-[:APPLIED_TO {status, applied_at}]->(Opening) # Applications +(Student)-[:WORKED_ON|COMPLETED]->(Work) # Student projects +(Faculty)-[:LED_PROJECT|PUBLISHED]->(Work) # Faculty research +(Work)-[:USED_TECH]->(Concept) # Technologies used +(Notification)-[:NOTIFIES]->(User) # User notifications +``` -### For Students +## Key Features & Endpoints -**Profile & Discovery** -* Create comprehensive profile with interests, previous projects, and research papers -* View personalized faculty recommendations with match scores across all departments -* Browse all research and project openings with advanced filtering (department, domain, tech stack) +### 1. Authentication & Authorization (`/auth`) -**Faculty Information** -* Access faculty profiles showing qualifications, domain interests, research papers, and completed projects -* View exact cabin location (block, floor, cabin number) for direct visits -* Explore faculty's current research and project openings with required skills and duration +**Endpoints**: -**Intelligent Matching** -* ML algorithm analyzes student interests and work history to recommend best-fit faculty -* Cross-departmental discovery breaks traditional silos -* Transparent match scores explain recommendation rationale +- `POST /auth/register` - Register new user (student/faculty) + - Input: email, password, name, role, roll_no/employee_id + - Creates User node with role-based labeling + +- `POST /auth/login` - JWT token generation + - Returns: `{access_token, token_type: "bearer"}` + - Token payload: `{sub: user_id, role: student|faculty, exp: timestamp}` -### For Faculty +**Security**: -**Profile & Visibility** -* Showcase qualifications (UG/PG/PhD), domain interests, and cabin details -* Highlight previous research papers with publication links and completed projects -* Build credibility through transparent work history +- Bcrypt password hashing +- JWT tokens required for all protected endpoints +- Role-based access control (RBAC) enforced via `get_current_user()` dependency + +### 2. Profile Management (`/users`) + +**Student Profile**: + +- `GET /users/student/profile/{user_id}` - Fetch student profile with skills, interests, projects, publications +- `PUT /users/student/profile` - Update profile (replaces old skills/interests/projects) + - Fields: name, phone, department, batch, bio, skills[], interests[], projects[], publications[] + - Uses `FOREACH` loops to create new relationships + +**Faculty Profile**: + +- `PUT /users/faculty/profile` - Update faculty profile + - Fields: name, designation, department, office_hours, cabin details (block/floor/number), ug_details[], pg_details[], phd_details[], domain_interests[], previous_work[] + +**File Upload**: + +- `POST /users/upload-profile-picture` - Upload profile images + - Stores in `uploads/` directory + - Returns URL: `http://localhost:8000/uploads/{uuid}.{ext}` + +### 3. ML-Powered Recommendations (`/recommend`) + +All recommendations use **graph traversal** with match score calculation: + +**For Faculty**: + +- `GET /recommend/faculty/students` - General student recommendations + - Formula: `(Shared Skills / Faculty Interests) × 100` + - Returns: student_id, name, dept, batch, pic, match_score, common_concepts + +- `GET /recommend/openings/{opening_id}/students` - Candidates for specific opening + - Formula: `(Matched Skills / Required Skills) × 100` + - Filters by CGPA threshold and target years + +**For Students**: + +- `GET /recommend/student/mentors` - Faculty mentor recommendations + - Logic: Count of shared research interests + - Returns: faculty_id, name, designation, pic, score, common_concepts + +- `GET /recommend/student/openings` - Recommended research openings + - Formula with recency boost: + + ``` + Base Score = (Matched Skills / Total Required) × 100 + Recency Multiplier: + < 7 days: 1.3x + 7-30 days: 1.0x + > 30 days: 0.8x + Final Score = min(Base × Multiplier, 100) + ``` + + - Automatically excludes already-applied openings + - Returns: opening_id, title, faculty_id, faculty_name, faculty_dept, faculty_pic, skills, match_score + +### 4. Opening Management (`/openings`) + +**Faculty Operations**: + +- `POST /openings/` - Create research/project opening + - Input: title, description, required_skills[], expected_duration, target_years[], min_cgpa, deadline + - Creates Opening node with `:REQUIRES` relationships to Concept nodes + - Sets status: 'Active' by default + +### 5. Application Tracking (`/applications`) + +**Student Operations**: + +- `POST /applications/apply/{opening_id}` - Submit application + - Validates eligibility (CGPA, target year) + - Prevents duplicate applications + - Creates `(Student)-[:APPLIED_TO {application_id, status: 'Pending', applied_at}]->(Opening)` + - Sends notification to faculty + +**Faculty Operations**: + +- `PUT /applications/status` - Update application status + - Input: opening_id, student_id, status ('Shortlisted'|'Rejected') + - Updates relationship status property + - Creates `:SHORTLISTED` relationship if accepted + - Sends notification to student + +### 6. Dashboard Endpoints (`/dashboard`) + +**Faculty Dashboard**: + +- `GET /dashboard/faculty/home` - Faculty home screen + - Returns: user_info, unread_count, recommended_students[], faculty_collaborations[], active_openings[] + - Supports filtering by skills/department + +- `GET /dashboard/faculty/menu` - Sidebar navigation data +- `GET /dashboard/faculty/all-students` - Browse all students with filters (search, department, batch) +- `GET /dashboard/faculty/collaborations` - Cross-faculty collaboration opportunities +- `GET /dashboard/faculty/student-profile/{student_id}` - View student public profile +- `GET /dashboard/faculty/projects` - Faculty's posted openings with applicant counts +- `GET /dashboard/faculty/projects/{project_id}/applicants` - View applicants for specific opening +- `GET /dashboard/faculty/projects/{project_id}/shortlisted` - View shortlisted students + +**Student Dashboard**: + +- `GET /dashboard/student/home` - Student home screen + - Returns: user_info, unread_count, recommended_openings[], all_openings[] + - Includes match scores and deadline dates + +- `GET /dashboard/student/menu` - Sidebar navigation data +- `GET /dashboard/student/all-faculty` - Browse all faculty with filters (search, department, domain) +- `GET /dashboard/student/faculty-profile/{faculty_id}` - View faculty public profile +- `GET /dashboard/student/applications` - Track application status + +**Interaction Endpoints**: + +- `POST /dashboard/shortlist/{student_id}` - Faculty shortlist student for opening +- `POST /dashboard/express-interest/{project_id}` - Student express interest in project + +### 7. Notifications (`/notifications`) + +- `GET /notifications/` - Fetch user notifications (last 20, sorted by date) + - Returns: id, message, type, is_read, date + +- `PUT /notifications/{notif_id}/read` - Mark notification as read + +**Notification Triggers**: + +- Student applies to opening → Faculty notified +- Faculty updates application status → Student notified +- Student expresses interest in collaboration → Faculty notified +- Faculty shortlists student → Student notified + +### 8. Portfolio Management + +**Student Projects** (`/student-projects`): + +- `POST /student-projects/` - Add student project/publication + - Creates `:Work:Project` or `:Work:Publication` node + - Links via `(Student)-[:COMPLETED|AUTHORED]->(Work)` + - Creates `(Work)-[:USED_TECH]->(Concept)` for each tool + +**Faculty Research** (`/faculty-projects`): + +- `POST /faculty-projects/` - Add faculty research work + - Supports dual types: 'publication' | 'project' + - Duplicate check by title + - Creates `(Faculty)-[:PUBLISHED|LED_PROJECT]->(Work)` + - Optional collaboration_type field for cross-faculty projects + +- `GET /faculty-projects/my-projects` - List faculty's openings with applicant/shortlisted counts +- `GET /faculty-projects/my-projects/{project_id}/applicants` - View applicants +- `GET /faculty-projects/my-projects/{project_id}/shortlisted` - View shortlisted students + +## Development Setup + +### Prerequisites + +- Python 3.11+ +- Neo4j 5.16+ (Community or Enterprise) +- pip package manager + +### Installation + +1. **Clone Repository**: + +```bash +git clone https://github.com/amrita-tensorclub/gurusetu-backend.git +cd gurusetu-backend +``` + +1. **Install Dependencies**: + +```bash +pip install -r requirements.txt +``` + +1. **Configure Environment**: +Create `.env` file in root directory: + +```env +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_password + +JWT_SECRET_KEY=your_secure_secret_key_here +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Optional +OPENAI_API_KEY=your_openai_key +``` + +1. **Initialize Database Schema**: + +```bash +python -m app.scripts.create_constraints +``` + +This creates: + +- Uniqueness constraints (user_id, email, opening_id, etc.) +- Vector similarity indexes for future semantic search + +1. **Run Development Server**: + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Access API docs at: `http://localhost:8000/docs` + +### Docker Deployment + +**Current Status**: Placeholder files exist (`Dockerfile`, `docker-compose.yml`) but are empty. + +**Recommended Production Setup**: + +**Dockerfile**: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**docker-compose.yml** (for local testing): + +```yaml +version: '3.8' + +services: + backend: + build: . + ports: + - "8000:8000" + environment: + - NEO4J_URI=bolt://neo4j:7687 + - NEO4J_USER=neo4j + - NEO4J_PASSWORD=${NEO4J_PASSWORD} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + depends_on: + - neo4j + + neo4j: + image: neo4j:5.16-community + ports: + - "7474:7474" # Browser UI + - "7687:7687" # Bolt protocol + environment: + - NEO4J_AUTH=neo4j/${NEO4J_PASSWORD} + - NEO4J_PLUGINS=["apoc"] + volumes: + - neo4j_data:/data + +volumes: + neo4j_data: +``` + +**Production Considerations**: + +- Use managed Neo4j (Neo4j Aura) for scalability +- Enable HTTPS/TLS: `neo4j+s://` URI scheme +- Reduce token expiry: `ACCESS_TOKEN_EXPIRE_MINUTES=15` +- Add health check endpoint +- Configure CORS properly for frontend domain + +## API Design Patterns + +### Database Session Management + +**Critical Pattern** - Sessions are NOT context managers: + +```python +session = db.get_session() +try: + result = session.run(query, param1=value1) + return [record.data() for record in result] +except Exception as e: + logger.error(f"Error: {e}") + raise HTTPException(status_code=500, detail="Error message") +finally: + session.close() # MUST close in finally block +``` + +### Concept Normalization + +All skills/interests stored as **lowercase** to prevent duplicates: + +```cypher +MERGE (c:Concept {name: toLower($skill_name)}) +MERGE (user)-[:HAS_SKILL]->(c) +``` + +### Profile Update Pattern + +1. Use `SET` with `COALESCE()` to preserve unmodified fields +2. Delete old relationships: `MATCH (user)-[r:HAS_SKILL]->() DELETE r` +3. Use `FOREACH` loops to create new relationships from arrays + +### Parameterized Queries + +**Always use parameters** - Never f-strings: + +```python +session.run(query, user_id=value) # ✅ Correct +session.run(f"MATCH (u {{id: '{value}'}})...") # ❌ SQL injection risk +``` + +## Testing & Validation + +**Verify Database Schema**: + +```cypher +SHOW CONSTRAINTS +SHOW INDEXES +``` + +**Test Auth Flow**: + +1. `POST /auth/register` with student/faculty data +2. `POST /auth/login` to get JWT token +3. Include in headers: `Authorization: Bearer ` + +**Sample Student Registration**: + +```json +POST /auth/register +{ + "email": "student@example.com", + "password": "securepass123", + "name": "John Doe", + "role": "student", + "roll_no": "AM.EN.U4CSE20001" +} +``` + +**Sample Faculty Login**: + +```json +POST /auth/login +{ + "email": "faculty@example.com", + "password": "securepass123" +} +``` + +## Troubleshooting + +**Neo4j Connection Issues**: + +- Verify Neo4j is running: `http://localhost:7474` +- Check credentials in `.env` file +- Ensure Bolt port 7687 is accessible + +**Authentication Errors**: + +- Verify JWT_SECRET_KEY is set +- Check token expiry (default 60 minutes) +- Ensure Bearer token format: `Authorization: Bearer ` + +**Duplicate Concept Nodes**: + +- Run concept normalization script if old data exists +- Ensure `toLower()` is used in all MERGE operations + +## Project Structure + +``` +gurusetu-backend/ +├── app/ +│ ├── main.py # FastAPI app initialization, CORS, router registration +│ ├── core/ +│ │ ├── config.py # Environment settings (Neo4j, JWT) +│ │ ├── database.py # Neo4j driver singleton +│ │ └── security.py # JWT token creation/validation, password hashing +│ ├── models/ +│ │ ├── auth.py # UserRegister, UserLogin, Token +│ │ ├── user.py # Profile update models +│ │ ├── openings.py # OpeningCreate +│ │ └── project.py # StudentWorkCreate (for projects/publications) +│ ├── routers/ +│ │ ├── auth.py # Registration, login +│ │ ├── users.py # Profile CRUD, file upload +│ │ ├── openings.py # Create openings +│ │ ├── recommendations.py # ML-powered matching +│ │ ├── applications.py # Application submission, status updates +│ │ ├── dashboard.py # Home screens, lists, public profiles +│ │ ├── notifications.py # Notification management +│ │ ├── student_projects.py # Student portfolio management +│ │ └── faculty_projects.py # Faculty research management +│ ├── services/ +│ │ ├── auth_service.py # Registration/login business logic +│ │ ├── rag_service.py # Recommendation algorithms +│ │ ├── graph_service.py # (Placeholder for future features) +│ │ └── embedding.py # (Placeholder for vector embeddings) +│ └── scripts/ +│ ├── create_constraints.py # Database schema initialization +│ └── sync_db.py # (Utility scripts) +├── uploads/ # User-uploaded files (profile pictures) +├── requirements.txt # Python dependencies +├── .env # Environment variables (not in repo) +├── Dockerfile # (Empty - needs implementation) +├── docker-compose.yml # (Empty - needs implementation) +└── README.md # This file +``` -**Opportunity Management** -* Post research openings with topic, required skills, and expected duration -* Create project openings specifying tech stack, description, and student requirements -* Manage multiple openings simultaneously with open/closed status -**Student Recommendations** -* Receive ML-powered student suggestions matching posted openings -* View student profiles with interests, previous work, and match scores -* Discover motivated students across departments proactively diff --git a/app/main.py b/app/main.py index 2dc0da7..50a3d19 100644 --- a/app/main.py +++ b/app/main.py @@ -8,17 +8,18 @@ # Import Routers from app.routers import ( - auth, - users, - openings, - recommendations, - student_projects, - faculty_projects, + auth, + users, + openings, + recommendations, + student_projects, + faculty_projects, dashboard, applications, notifications ) + @asynccontextmanager async def lifespan(app: FastAPI): loop = asyncio.get_running_loop() @@ -50,17 +51,38 @@ async def lifespan(app: FastAPI): ) # ------------------------------------------------------------ + @app.get("/") def read_root(): return {"message": "Guru Setu Backend is Running 🚀"} + +@app.get("/health") +def health_check(): + """Health check endpoint for monitoring and load balancers.""" + session = None + try: + session = db.get_session() + session.run("RETURN 1") + return {"status": "healthy", "database": "connected"} + except Exception as e: + return {"status": "unhealthy", "database": "disconnected", "error": str(e)} + finally: + if session: + session.close() + + # Register Routers app.include_router(auth.router, prefix="/auth", tags=["Auth"]) app.include_router(users.router, prefix="/users", tags=["Profiles"]) app.include_router(openings.router, prefix="/openings", tags=["Openings"]) app.include_router(recommendations.router, prefix="/recommend", tags=["AI"]) -app.include_router(student_projects.router, prefix="/student-projects", tags=["Student Portfolio"]) -app.include_router(faculty_projects.router, prefix="/faculty-projects", tags=["Faculty Research"]) +app.include_router(student_projects.router, + prefix="/student-projects", tags=["Student Portfolio"]) +app.include_router(faculty_projects.router, + prefix="/faculty-projects", tags=["Faculty Research"]) app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) -app.include_router(applications.router, prefix="/applications", tags=["Applications"]) # <--- NEW REGISTER -app.include_router(notifications.router, prefix="/notifications", tags=["Notifications"]) # <--- 2. REGISTER IT \ No newline at end of file +app.include_router(applications.router, prefix="/applications", + tags=["Applications"]) # <--- NEW REGISTER +app.include_router(notifications.router, prefix="/notifications", + tags=["Notifications"]) # <--- 2. REGISTER IT diff --git a/app/models/openings.py b/app/models/openings.py index 0c10231..11b0b08 100644 --- a/app/models/openings.py +++ b/app/models/openings.py @@ -2,6 +2,7 @@ from typing import List, Optional from datetime import date + class OpeningCreate(BaseModel): title: str description: str @@ -12,3 +13,15 @@ class OpeningCreate(BaseModel): target_years: List[str] = [] # ["3rd", "4th"] min_cgpa: Optional[float] = None # 8.0 deadline: date # "31 Dec 2023" + + +class OpeningUpdate(BaseModel): + """All fields optional for partial updates.""" + title: Optional[str] = None + description: Optional[str] = None + required_skills: Optional[List[str]] = None + expected_duration: Optional[str] = None + target_years: Optional[List[str]] = None + min_cgpa: Optional[float] = None + deadline: Optional[date] = None + status: Optional[str] = None # "Active" or "Closed" diff --git a/app/routers/applications.py b/app/routers/applications.py index b36d241..c1b0e68 100644 --- a/app/routers/applications.py +++ b/app/routers/applications.py @@ -2,17 +2,70 @@ from pydantic import BaseModel from app.core.database import db from app.core.security import get_current_user -from datetime import datetime +from datetime import datetime, date import uuid +import logging +logger = logging.getLogger(__name__) router = APIRouter() # --- Model for Status Update --- + + class ApplicationStatusUpdate(BaseModel): opening_id: str student_id: str status: str # "Shortlisted" or "Rejected" + +@router.delete("/withdraw/{opening_id}") +def withdraw_application(opening_id: str, current_user: dict = Depends(get_current_user)): + """Withdraw a pending application. Only pending applications can be withdrawn.""" + if current_user["role"].lower() != "student": + raise HTTPException( + status_code=403, detail="Only students can withdraw applications") + + user_id = current_user["user_id"] + session = db.get_session() + + try: + # Check if application exists and is pending + check_query = """ + MATCH (u:User {user_id: $uid})-[r:APPLIED_TO]->(o:Opening {id: $oid}) + RETURN r.status AS status + """ + result = session.run(check_query, uid=user_id, oid=opening_id).single() + + if not result: + raise HTTPException( + status_code=404, detail="Application not found") + + if result["status"].lower() != "pending": + raise HTTPException( + status_code=400, + detail=f"Cannot withdraw application with status '{result['status']}'" + ) + + # Delete the application relationship + withdraw_query = """ + MATCH (u:User {user_id: $uid})-[r:APPLIED_TO]->(o:Opening {id: $oid}) + DELETE r + RETURN o.title AS title + """ + delete_result = session.run( + withdraw_query, uid=user_id, oid=opening_id).single() + + return {"message": f"Application for '{delete_result['title']}' withdrawn successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Withdraw error: {e}") + raise HTTPException( + status_code=500, detail="Failed to withdraw application") + finally: + session.close() + + @router.post("/apply/{opening_id}") def apply_to_opening(opening_id: str, current_user: dict = Depends(get_current_user)): # 1. Check Role @@ -23,18 +76,34 @@ def apply_to_opening(opening_id: str, current_user: dict = Depends(get_current_u session = db.get_session() try: - # 2. Check if Opening Exists & Get Faculty ID - # FIX 1: Changed [:POSTED_BY] to [:POSTED] to match your Opening creation logic + # 2. Check if Opening Exists & Get Faculty ID + Deadline check_query = """ MATCH (f:User)-[:POSTED]->(o) WHERE o.id = $oid - RETURN o, f + RETURN o, f, o.deadline AS deadline, o.status AS status """ result = session.run(check_query, oid=opening_id).single() - + if not result or not result["o"]: raise HTTPException(status_code=404, detail="Opening not found") - + + # 2b. Check if opening is closed + if result["status"] and result["status"].lower() == "closed": + raise HTTPException( + status_code=400, detail="This opening is closed and no longer accepting applications") + + # 2c. Check deadline + deadline = result["deadline"] + if deadline: + # Handle both date and datetime objects from Neo4j + if hasattr(deadline, 'to_native'): + deadline = deadline.to_native() + if isinstance(deadline, datetime): + deadline = deadline.date() + if deadline < date.today(): + raise HTTPException( + status_code=400, detail="Application deadline has passed") + # 3. Check if Already Applied exists_query = """ MATCH (u:User {user_id: $uid})-[r:APPLIED_TO]->(o) @@ -42,7 +111,8 @@ def apply_to_opening(opening_id: str, current_user: dict = Depends(get_current_u RETURN r """ if session.run(exists_query, uid=user_id, oid=opening_id).single(): - raise HTTPException(status_code=400, detail="You have already applied to this project") + raise HTTPException( + status_code=400, detail="You have already applied to this project") # 4. Create Application & Notification # FIX 2: Changed [:HAS_NOTIFICATION] to [:NOTIFIES] and reversed direction @@ -74,20 +144,23 @@ def apply_to_opening(opening_id: str, current_user: dict = Depends(get_current_u RETURN u.user_id """ - - session.run(apply_query, uid=user_id, oid=opening_id, app_id=str(uuid.uuid4()), notif_id=str(uuid.uuid4())) - + + session.run(apply_query, uid=user_id, oid=opening_id, + app_id=str(uuid.uuid4()), notif_id=str(uuid.uuid4())) + return {"message": "Application submitted successfully"} - + except Exception as e: print(f"Application Error: {e}") - raise HTTPException(status_code=500, detail="Server error processing application") + raise HTTPException( + status_code=500, detail="Server error processing application") finally: session.close() + @router.put("/status") def update_application_status( - data: ApplicationStatusUpdate, + data: ApplicationStatusUpdate, current_user: dict = Depends(get_current_user) ): if current_user["role"].lower() != "faculty": @@ -129,17 +202,17 @@ def update_application_status( """ session.run( - cypher, - oid=data.opening_id, - sid=data.student_id, + cypher, + oid=data.opening_id, + sid=data.student_id, status=data.status, notif_id=str(uuid.uuid4()) ) - + return {"message": f"Applicant marked as {data.status}"} except Exception as e: print(f"Status Update Error: {e}") raise HTTPException(status_code=500, detail="Failed to update status") finally: - session.close() \ No newline at end of file + session.close() diff --git a/app/routers/notifications.py b/app/routers/notifications.py index 4c54bd7..750fe89 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -4,6 +4,7 @@ router = APIRouter() + @router.get("/") def get_notifications(current_user: dict = Depends(get_current_user)): user_id = current_user["user_id"] @@ -17,20 +18,21 @@ def get_notifications(current_user: dict = Depends(get_current_user)): ORDER BY n.created_at DESC LIMIT 20 """ results = session.run(query, uid=user_id) - + notifs = [] for r in results: notifs.append({ - "id": r["id"], - "message": r["message"], - "type": r["type"], - "is_read": r["is_read"], + "id": r["id"], + "message": r["message"], + "type": r["type"], + "is_read": r["is_read"], "date": r["date"].isoformat() if r["date"] else "" }) return notifs finally: session.close() + @router.put("/{notif_id}/read") def mark_notification_read(notif_id: str, current_user: dict = Depends(get_current_user)): session = db.get_session() @@ -42,4 +44,22 @@ def mark_notification_read(notif_id: str, current_user: dict = Depends(get_curre session.run(query, nid=notif_id, uid=current_user["user_id"]) return {"message": "Marked as read"} finally: - session.close() \ No newline at end of file + session.close() + + +@router.put("/read-all") +def mark_all_notifications_read(current_user: dict = Depends(get_current_user)): + """Mark all notifications as read for the current user.""" + session = db.get_session() + try: + query = """ + MATCH (n:Notification)-[:NOTIFIES]->(u:User {user_id: $uid}) + WHERE n.is_read = false + SET n.is_read = true + RETURN count(n) AS marked_count + """ + result = session.run(query, uid=current_user["user_id"]).single() + count = result["marked_count"] if result else 0 + return {"message": f"Marked {count} notifications as read"} + finally: + session.close() diff --git a/app/routers/openings.py b/app/routers/openings.py index 1c73aba..877a302 100644 --- a/app/routers/openings.py +++ b/app/routers/openings.py @@ -1,11 +1,152 @@ from fastapi import APIRouter, HTTPException, Depends -from app.models.openings import OpeningCreate +from app.models.openings import OpeningCreate, OpeningUpdate from app.core.database import db -from app.core.security import get_current_user # <--- THIS WAS MISSING +from app.core.security import get_current_user import uuid +import logging +logger = logging.getLogger(__name__) router = APIRouter() + +@router.get("/{opening_id}") +def get_opening(opening_id: str): + """Get a single opening by ID with faculty info and required skills.""" + session = db.get_session() + try: + query = """ + MATCH (f:User)-[:POSTED]->(o:Opening {id: $opening_id}) + OPTIONAL MATCH (o)-[:REQUIRES]->(c:Concept) + RETURN o.id AS id, + o.title AS title, + o.description AS description, + o.expected_duration AS expected_duration, + o.target_years AS target_years, + o.min_cgpa AS min_cgpa, + o.deadline AS deadline, + o.status AS status, + o.created_at AS created_at, + f.user_id AS faculty_id, + f.name AS faculty_name, + f.department AS faculty_department, + collect(c.name) AS required_skills + """ + result = session.run(query, opening_id=opening_id).single() + + if not result: + raise HTTPException(status_code=404, detail="Opening not found") + + return dict(result) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching opening: {e}") + raise HTTPException(status_code=500, detail="Failed to fetch opening") + finally: + session.close() + + +@router.put("/{opening_id}") +def update_opening( + opening_id: str, + update: OpeningUpdate, + current_user: dict = Depends(get_current_user) +): + """Update an opening. Only the faculty who posted it can update.""" + if current_user["role"].lower() != "faculty": + raise HTTPException( + status_code=403, detail="Only faculty can update openings") + + session = db.get_session() + try: + # Verify ownership + ownership_query = """ + MATCH (f:User {user_id: $faculty_id})-[:POSTED]->(o:Opening {id: $opening_id}) + RETURN o + """ + if not session.run(ownership_query, faculty_id=current_user["user_id"], opening_id=opening_id).single(): + raise HTTPException( + status_code=404, detail="Opening not found or you don't have permission") + + # Build dynamic SET clause for provided fields + update_data = update.model_dump(exclude_none=True) + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + # Handle skills separately (requires relationship update) + skills = update_data.pop("required_skills", None) + + # Update scalar fields + if update_data: + set_clauses = ", ".join( + [f"o.{k} = ${k}" for k in update_data.keys()]) + update_query = f""" + MATCH (o:Opening {{id: $opening_id}}) + SET {set_clauses} + RETURN o.id + """ + session.run(update_query, opening_id=opening_id, **update_data) + + # Update skills if provided + if skills is not None: + skills_query = """ + MATCH (o:Opening {id: $opening_id}) + OPTIONAL MATCH (o)-[r:REQUIRES]->(:Concept) + DELETE r + WITH o + UNWIND $skills AS skill_name + MERGE (c:Concept {name: toLower(skill_name)}) + MERGE (o)-[:REQUIRES]->(c) + """ + session.run(skills_query, opening_id=opening_id, skills=skills) + + return {"message": "Opening updated successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating opening: {e}") + raise HTTPException(status_code=500, detail="Failed to update opening") + finally: + session.close() + + +@router.delete("/{opening_id}") +def delete_opening( + opening_id: str, + current_user: dict = Depends(get_current_user) +): + """Delete an opening. Only the faculty who posted it can delete.""" + if current_user["role"].lower() != "faculty": + raise HTTPException( + status_code=403, detail="Only faculty can delete openings") + + session = db.get_session() + try: + # Verify ownership and delete (including relationships) + delete_query = """ + MATCH (f:User {user_id: $faculty_id})-[:POSTED]->(o:Opening {id: $opening_id}) + OPTIONAL MATCH (o)-[r]-() // All relationships + OPTIONAL MATCH (n:Notification {trigger_id: $opening_id}) + DETACH DELETE o, n + RETURN count(o) AS deleted + """ + result = session.run( + delete_query, faculty_id=current_user["user_id"], opening_id=opening_id).single() + + if not result or result["deleted"] == 0: + raise HTTPException( + status_code=404, detail="Opening not found or you don't have permission") + + return {"message": "Opening deleted successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting opening: {e}") + raise HTTPException(status_code=500, detail="Failed to delete opening") + finally: + session.close() + + @router.post("/") def create_opening( opening: OpeningCreate, @@ -13,7 +154,7 @@ def create_opening( ): if current_user["role"].lower() != "faculty": raise HTTPException( - status_code=403, + status_code=403, detail="Only faculty can create openings" ) @@ -22,7 +163,7 @@ def create_opening( opening_id = str(uuid.uuid4()) try: - # ... inside create_opening function ... + # ... inside create_opening function ... query = """ MATCH (f:User {user_id: $faculty_id}) @@ -68,4 +209,4 @@ def create_opening( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: - session.close() \ No newline at end of file + session.close()