shrisdiablo commited on
Commit
a5b469a
·
2 Parent(s): 603d27f 3ce5685

Merge remote-tracking branch 'gh/main' into feat/chatbot

Browse files
requirements.txt CHANGED
@@ -1,9 +1,11 @@
 
1
  alembic==1.17.1
2
  annotated-doc==0.0.3
3
  annotated-types==0.7.0
4
  anyio==4.11.0
5
  asyncpg==0.30.0
6
  bcrypt==3.2.2
 
7
  certifi==2025.11.12
8
  cffi==2.0.0
9
  charset-normalizer==3.4.4
@@ -17,11 +19,17 @@ fastapi==0.121.0
17
  filelock==3.20.0
18
  flatbuffers==25.9.23
19
  fsspec==2025.10.0
20
- git-filter-repo==2.47.0
 
 
 
 
 
21
  greenlet==3.2.4
22
  h11==0.16.0
23
  hf-xet==1.2.0
24
  httpcore==1.0.9
 
25
  httpx==0.28.1
26
  huggingface-hub==0.36.0
27
  humanfriendly==10.0
@@ -29,18 +37,22 @@ idna==3.11
29
  Mako==1.3.10
30
  MarkupSafe==3.0.3
31
  mpmath==1.3.0
32
- numpy==2.2.6
 
33
  onnxruntime==1.23.2
34
  packaging==25.0
35
  passlib==1.7.4
36
  pgvector==0.4.1
 
37
  protobuf==6.33.1
38
  psycopg2-binary==2.9.11
39
  pyasn1==0.6.1
 
40
  pycparser==2.23
41
  pydantic==2.12.4
42
  pydantic-settings==2.12.0
43
  pydantic_core==2.41.5
 
44
  PyPDF2==3.0.1
45
  python-dotenv==1.2.1
46
  python-jose==3.5.0
@@ -48,6 +60,7 @@ python-multipart==0.0.20
48
  PyYAML==6.0.3
49
  regex==2025.11.3
50
  requests==2.32.5
 
51
  rsa==4.9.1
52
  safetensors==0.6.2
53
  six==1.17.0
@@ -61,5 +74,6 @@ tqdm==4.67.1
61
  transformers==4.57.1
62
  typing-inspection==0.4.2
63
  typing_extensions==4.15.0
 
64
  urllib3==2.5.0
65
  uvicorn==0.38.0
 
1
+ aiosmtplib==5.0.0
2
  alembic==1.17.1
3
  annotated-doc==0.0.3
4
  annotated-types==0.7.0
5
  anyio==4.11.0
6
  asyncpg==0.30.0
7
  bcrypt==3.2.2
8
+ cachetools==6.2.2
9
  certifi==2025.11.12
10
  cffi==2.0.0
11
  charset-normalizer==3.4.4
 
19
  filelock==3.20.0
20
  flatbuffers==25.9.23
21
  fsspec==2025.10.0
22
+ google-api-core==2.28.1
23
+ google-api-python-client==2.187.0
24
+ google-auth==2.41.1
25
+ google-auth-httplib2==0.2.1
26
+ google-auth-oauthlib==1.2.3
27
+ googleapis-common-protos==1.72.0
28
  greenlet==3.2.4
29
  h11==0.16.0
30
  hf-xet==1.2.0
31
  httpcore==1.0.9
32
+ httplib2==0.31.0
33
  httpx==0.28.1
34
  huggingface-hub==0.36.0
35
  humanfriendly==10.0
 
37
  Mako==1.3.10
38
  MarkupSafe==3.0.3
39
  mpmath==1.3.0
40
+ numpy==2.3.5
41
+ oauthlib==3.3.1
42
  onnxruntime==1.23.2
43
  packaging==25.0
44
  passlib==1.7.4
45
  pgvector==0.4.1
46
+ proto-plus==1.26.1
47
  protobuf==6.33.1
48
  psycopg2-binary==2.9.11
49
  pyasn1==0.6.1
50
+ pyasn1_modules==0.4.2
51
  pycparser==2.23
52
  pydantic==2.12.4
53
  pydantic-settings==2.12.0
54
  pydantic_core==2.41.5
55
+ pyparsing==3.2.5
56
  PyPDF2==3.0.1
57
  python-dotenv==1.2.1
58
  python-jose==3.5.0
 
60
  PyYAML==6.0.3
61
  regex==2025.11.3
62
  requests==2.32.5
63
+ requests-oauthlib==2.0.0
64
  rsa==4.9.1
65
  safetensors==0.6.2
66
  six==1.17.0
 
74
  transformers==4.57.1
75
  typing-inspection==0.4.2
76
  typing_extensions==4.15.0
77
+ uritemplate==4.2.0
78
  urllib3==2.5.0
79
  uvicorn==0.38.0
src/main.py CHANGED
@@ -5,6 +5,8 @@ from src.auth.router import router as auth_router
5
  from src.chatbot.router import router as chatbot
6
  from src.core.database import init_db
7
  from src.home.router import router as home_router
 
 
8
  from fastapi.staticfiles import StaticFiles
9
 
10
 
@@ -18,6 +20,7 @@ app.include_router(home_router, prefix="/home", tags=["Home"])
18
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
19
  AUDIO_DIR = os.path.join(BASE_DIR, "static", "audio")
20
 
 
21
  app.mount("/static/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
22
  print("Serving audio from:", AUDIO_DIR)
23
 
@@ -25,6 +28,8 @@ app.include_router(auth_router)
25
 
26
  app.include_router(chatbot)
27
 
 
 
28
 
29
  @app.get("/")
30
  def root():
 
5
  from src.chatbot.router import router as chatbot
6
  from src.core.database import init_db
7
  from src.home.router import router as home_router
8
+ from src.notifications.router import router as notifications_router
9
+
10
  from fastapi.staticfiles import StaticFiles
11
 
12
 
 
20
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
21
  AUDIO_DIR = os.path.join(BASE_DIR, "static", "audio")
22
 
23
+ app.include_router(profile)
24
  app.mount("/static/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
25
  print("Serving audio from:", AUDIO_DIR)
26
 
 
28
 
29
  app.include_router(chatbot)
30
 
31
+ app.include_router(notifications_router)
32
+
33
 
34
  @app.get("/")
35
  def root():
src/notifications/fcm.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import httpx
4
+ from google.oauth2 import service_account
5
+ import google.auth.transport.requests
6
+
7
+ # Your Firebase project ID (from project settings)
8
+ FCM_PROJECT_ID = "yuvabe-478505" # <-- change this
9
+
10
+ # Path to your service account file
11
+ SERVICE_ACCOUNT_FILE = os.path.join(
12
+ os.path.dirname(__file__), "yuvabe-478505-09433c5e6e33.json"
13
+ )
14
+
15
+
16
+ def get_access_token():
17
+ """Generate OAuth2 access token for FCM HTTP v1."""
18
+ scopes = ["https://www.googleapis.com/auth/firebase.messaging"]
19
+
20
+ credentials = service_account.Credentials.from_service_account_file(
21
+ SERVICE_ACCOUNT_FILE, scopes=scopes
22
+ )
23
+
24
+ request = google.auth.transport.requests.Request()
25
+ credentials.refresh(request)
26
+
27
+ return credentials.token
28
+
29
+
30
+ async def send_fcm(tokens: list[str], title: str, body: str, data: dict | None = None):
31
+ """Send push notifications using Firebase HTTP v1."""
32
+ if not tokens:
33
+ return
34
+
35
+ access_token = get_access_token()
36
+
37
+ url = f"https://fcm.googleapis.com/v1/projects/{FCM_PROJECT_ID}/messages:send"
38
+
39
+ headers = {
40
+ "Authorization": f"Bearer {access_token}",
41
+ "Content-Type": "application/json; UTF-8",
42
+ }
43
+
44
+ # FCM v1 sends only one token per message
45
+ for token in tokens:
46
+ message = {
47
+ "message": {
48
+ "token": token,
49
+ "notification": {"title": title, "body": body},
50
+ "data": data or {},
51
+ }
52
+ }
53
+
54
+ async with httpx.AsyncClient() as client:
55
+ res = await client.post(url, json=message, headers=headers)
56
+ print("FCM Response:", res.text)
src/notifications/router.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.notifications.schemas import RegisterDeviceRequest
2
+ from src.notifications.service import register_device
3
+ from fastapi import APIRouter, Depends
4
+ from src.auth.utils import get_current_user
5
+ from src.core.database import get_async_session
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ router = APIRouter(prefix="/notifications", tags=["Notifications"])
9
+
10
+
11
+ @router.post("/register-device")
12
+ async def register_device_route(
13
+ body: RegisterDeviceRequest,
14
+ session: AsyncSession = Depends(get_async_session),
15
+ user=Depends(get_current_user),
16
+ ):
17
+ device = await register_device(user, body, session)
18
+ return {"message": "Device registered", "device": str(device.id)}
src/notifications/schemas.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ class RegisterDeviceRequest(BaseModel):
4
+ device_token: str
5
+ platform: str
6
+ device_model: str
src/notifications/service.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.profile.models import UserDevices
2
+ from src.notifications.schemas import RegisterDeviceRequest
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy import select
5
+ from datetime import datetime
6
+
7
+
8
+ async def register_device(
9
+ user_id: str, body: RegisterDeviceRequest, session: AsyncSession
10
+ ):
11
+
12
+ # Check if the user already has this token saved
13
+ stmt = select(UserDevices).where(
14
+ UserDevices.user_id == user_id,
15
+ UserDevices.device_token == body.device_token,
16
+ )
17
+ result = await session.execute(stmt)
18
+ device = result.scalar_one_or_none()
19
+
20
+ if device:
21
+ device.platform = body.platform
22
+ device.device_model = body.device_model
23
+ device.last_seen = datetime.utcnow()
24
+ device.updated_at = datetime.utcnow()
25
+
26
+ await session.commit()
27
+ await session.refresh(device)
28
+ return device
29
+
30
+ # Create new device entry
31
+ new_device = UserDevices(
32
+ user_id=user_id,
33
+ device_token=body.device_token,
34
+ platform=body.platform,
35
+ device_model=body.device_model,
36
+ )
37
+
38
+ session.add(new_device)
39
+ await session.commit()
40
+ await session.refresh(new_device)
41
+ return new_device
42
+
43
+ from sqlalchemy import select
44
+ from src.profile.models import UserDevices
45
+
46
+ async def get_user_device_tokens(session, user_id):
47
+ stmt = select(UserDevices.device_token).where(UserDevices.user_id == user_id)
48
+ rows = (await session.execute(stmt)).all()
49
+ return [r[0] for r in rows]
src/notifications/yuvabe-478505-09433c5e6e33.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "service_account",
3
+ "project_id": "yuvabe-478505",
4
+ "private_key_id": "09433c5e6e33e97ca2ee5b9a8e7c9408ea9c3412",
5
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJOvpUxKdDXz4a\nHyU1WBUTIQnIvoMc3xCRLFfQn1IUbbTZ8QetoChEUHDBR0uAAMuxSx2IqOXIkmlX\n52iHTffo7pqLg5AaoEUMFG3WzMs9ek5V0DE7vX+zNIecIDk8rlj/Je2k3UFS/vbv\nW7FCrGbaxr5yREuAo9p5+PPfFJj0DjaE8ZlszIzZ1cElVyXzrwNhUpdMqoa2Ozge\nHh6nsWVkO8NVFKYxIx+NXe0rh+VpaGmghydG7gfr+VnjeUEv6PrwScwP8kVrY/87\ny4UxRFD+mK7kjASezHvmqbOmlLY5xMIPVBjVbdPKqBaYFkYhTKAJjrf60OCc4xyI\nVFpDGwadAgMBAAECggEAAIguobs8WvX9Psnuyf+P3LNVaImyZIjlbRDSMZu+No4c\nsIfN1qRp/tY8mhIzbaTy5ObXLuWNZ/SVITWcJeFroprA31YLczqRvCiwqqTzc5fn\nbZwliSwk0oc0xZGjPRkT5KbHxEwOcGb6paLXKt80TWdBmSE7lt04BmMFWAVgqyJ+\nRx90LIFs2Lle+2r76EDKrT0CJb0m3CpSDdr9xWD367b5blnNHvSE0Fp+xzrLXi0V\nELfplbdTQqyo5ol/iUaOWyZmXI80EXy5FQfeSI67ctt9A+JH/wwtU+5S03zyXDa8\nW9/LDXsr29KVtgQsI/L31V74WW9fuf73WDfx/hAR0QKBgQD7IxbiXHtg0nSwXWcb\nejqzx03alizcW0xnnoXHw+1Ps+Gx3XY9Jzj6JrEh8bxIVo9xacRJgKMaUBf1CWP2\nTpHx4Y9S4leObVA+aUNikDeYdU0sZ22fJTHxXKfj4KIUgDw/4SFRgHSeScecsy89\n2tP5ZE44DUdlDJGHFseza8WOrQKBgQDNIH8IZMPoNN1AYI2q0RReVWeJvRFIXOHu\nKRYe7n6QD9Bu0QqwUzhuu40zEQ2X8gs4yRRtSNOk1f7V+CenkQq+Ie3N1ThDdJij\nQ6TatDxdh4p/WlNmGO6UuRFQ+aGWCS8BGC23CsLF83oZstXO1mFUgVcT1NN+thU8\ngzjai+IFsQKBgQC41/nnAGT7PcwPZ6AVdGCypDZkdfZs9nIFLoOPJmGMMX0kOjnm\nBvGEBWiI8HFB2RxZQJzt8NWb72nCvHer+ean3vpr6hbBySmv1jB34mhZObVkwnfI\nFEtvOGCGdHc3ma8+4UhxwZeUTf+zEZvq7h7pR+/eh7+N27Ndd+Hi6KcPPQKBgBCh\n6+sAUKpJbw9DRPluzpn/js+qVvZpIaCwEC0d5YFE5v+1T4qQlOjVqFNVtKZ3Z9WI\n6HEcEJ0zBODQqFZ7+kUEd0XTXiaKE75ZQ8rABo0G6oH9DvoeV2oTv4WzWBjUUc1i\n6oIHC1gFsAbZa2DEHUw+2JKxFR0XIo2vjjKaWQ9hAoGBALR5k276J0vzGfZhK07V\nZrjjQx+n1Mm4iQW+lGzL21D4p8T7b+jVDW+bGvCjm7X68ZNREDY8pYRjeAc4EN/U\nvLVR1q2BH7qOlVUxA6Wa3akCQeGMcUAvWfQ9wlC0baqu2yPxld8rMZSXnYB5qHkC\nsMmDU5Zruq4NMQQ+65rR5kFS\n-----END PRIVATE KEY-----\n",
6
+ "client_email": "firebase-adminsdk-fbsvc@yuvabe-478505.iam.gserviceaccount.com",
7
+ "client_id": "110219410819577512835",
8
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
9
+ "token_uri": "https://oauth2.googleapis.com/token",
10
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
11
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40yuvabe-478505.iam.gserviceaccount.com",
12
+ "universe_domain": "googleapis.com"
13
+ }
src/profile/notify.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.notifications.service import get_user_device_tokens
2
+ from src.notifications.fcm import send_fcm
3
+
4
+
5
+ # -------------------------------
6
+ # SEND TO MENTOR + LEAD
7
+ # -------------------------------
8
+ async def send_leave_request_notification(session, user, leave, mentor_id, lead_id):
9
+ title = "New Leave Request"
10
+ body = f"{user.user_name} requested leave"
11
+
12
+ tokens = []
13
+ tokens += await get_user_device_tokens(session, mentor_id)
14
+ tokens += await get_user_device_tokens(session, lead_id)
15
+
16
+ await send_fcm(
17
+ tokens,
18
+ title,
19
+ body,
20
+ {
21
+ "type": "leave_request",
22
+ "screen": "MentorApproval",
23
+ "leave_id": str(leave.id),
24
+ },
25
+ )
26
+
27
+
28
+ # -------------------------------
29
+ # SEND TO USER + TEAM LEAD
30
+ # -------------------------------
31
+ async def send_leave_status_notification(session, leave, mentor_name):
32
+ title = f"Leave {leave.status}"
33
+ body = f"Your leave was {leave.status.lower()} by {mentor_name}"
34
+
35
+ # Send to USER
36
+ tokens = await get_user_device_tokens(session, leave.user_id)
37
+ await send_fcm(
38
+ tokens,
39
+ title,
40
+ body,
41
+ {
42
+ "type": "leave_status",
43
+ "screen": "LeaveDetails",
44
+ "leave_id": str(leave.id),
45
+ },
46
+ )
47
+
48
+ # Send to TEAM LEAD
49
+ tokens = await get_user_device_tokens(session, leave.lead_id)
50
+ await send_fcm(
51
+ tokens,
52
+ title,
53
+ f"Leave {leave.status} for user {leave.user_id}",
54
+ {
55
+ "type": "lead_update",
56
+ "screen": "LeaveDetails",
57
+ "leave_id": str(leave.id),
58
+ },
59
+ )
src/profile/router.py CHANGED
@@ -1,282 +1,386 @@
1
- # from src.profile.service import send_email_service
2
- # from src.profile.schemas import SendMailRequest
3
- # from src.profile.utils import build_auth_url
4
- # from src.profile.utils import exchange_code_for_tokens
5
- # from src.profile.service import USER_TOKEN_STORE
6
- # from src.profile.utils import send_email
7
- # from src.profile.service import list_user_assets
8
- # from fastapi.routing import APIRouter
9
- # from src.core.database import get_async_session
10
- # from src.auth.utils import get_current_user
11
- # from src.auth.schemas import BaseResponse
12
- # from sqlalchemy.ext.asyncio.session import AsyncSession
13
- # from fastapi.params import Depends
14
- # from .schemas import UpdateProfileRequest
15
- # from src.profile.service import update_user_profile
16
- # from sqlmodel import select
17
- # from src.core.models import Users, Teams, Roles, UserTeamsRole
18
- # from fastapi import APIRouter, Query, HTTPException ,BackgroundTasks
19
- # from fastapi.responses import RedirectResponse, JSONResponse
20
- # import httpx
21
- # from fastapi import APIRouter, Depends, HTTPException, status
22
- # from sqlmodel.ext.asyncio.session import AsyncSession
23
- # from src.auth.utils import get_current_user # adjust path
24
- # from src.profile.schemas import (
25
- # ApplyLeaveRequest,
26
- # ApproveRejectRequest,
27
- # LeaveResponse,
28
- # BalanceResponse,
29
- # DeviceTokenIn,
30
- # )
31
- # from src.profile import service
32
- # from typing import List
33
 
34
 
35
  # router = APIRouter(prefix="/profile", tags=["Profile"])
36
 
37
 
38
- # @router.post("/apply", response_model=LeaveResponse)
39
- # async def apply_leave_endpoint(
40
- # payload: ApplyLeaveRequest,
41
- # session: AsyncSession = Depends(get_async_session),
42
- # current_user=Depends(get_current_user),
43
- # ):
44
- # user_id = current_user
45
- # leave = await service.apply_leave(session, user_id, payload)
46
- # return leave
47
-
48
-
49
- # @router.get("/pending", response_model=List[LeaveResponse])
50
- # async def pending_leaves(
51
- # session: AsyncSession = Depends(get_async_session),
52
- # current_user=Depends(get_current_user),
53
- # ):
54
- # user_id = current_user
55
- # leaves = await service.get_pending_leaves_for_approver(session, user_id)
56
- # return leaves
57
-
58
-
59
- # @router.get("/my", response_model=List[LeaveResponse])
60
- # async def my_leaves(
61
- # session: AsyncSession = Depends(get_async_session),
62
- # current_user=Depends(get_current_user),
63
- # ):
64
- # leaves = await service.get_my_leaves(session, current_user)
65
- # return leaves
66
-
67
-
68
- # @router.get("/team", response_model=List[LeaveResponse])
69
- # async def team_leaves(
70
- # session: AsyncSession = Depends(get_async_session),
71
- # current_user=Depends(get_current_user),
72
- # ):
73
- # leaves = await service.get_team_leaves(session, current_user)
74
- # return leaves
75
-
76
-
77
- # @router.post("/{leave_id}/approve", response_model=LeaveResponse)
78
- # async def approve_leave_endpoint(
79
- # leave_id: str,
80
- # payload: ApproveRejectRequest,
81
- # session: AsyncSession = Depends(get_async_session),
82
- # current_user=Depends(get_current_user),
83
- # ):
84
- # leave = await service.approve_leave(
85
- # session, current_user, leave_id, comment=payload.comment
86
- # )
87
- # return leave
88
-
89
-
90
- # @router.post("/{leave_id}/reject", response_model=LeaveResponse)
91
- # async def reject_leave_endpoint(
92
- # leave_id: str,
93
- # payload: ApproveRejectRequest,
94
- # session: AsyncSession = Depends(get_async_session),
95
- # current_user=Depends(get_current_user),
96
- # ):
97
- # leave = await service.reject_leave(
98
- # session,
99
- # current_user,
100
- # leave_id,
101
- # reject_reason=payload.reject_reason,
102
- # comment=payload.comment,
103
- # )
104
- # return leave
105
-
106
-
107
- # @router.get("/balance", response_model=List[BalanceResponse])
108
- # async def get_balance(
109
- # session: AsyncSession = Depends(get_async_session),
110
- # current_user=Depends(get_current_user),
111
- # ):
112
- # return await service.get_leave_balance(session, current_user)
113
-
114
-
115
- # @router.post("/device-token")
116
- # async def save_device_token(
117
- # payload: DeviceTokenIn,
118
- # session: AsyncSession = Depends(get_async_session),
119
- # current_user=Depends(get_current_user),
120
- # ):
121
- # tokens = await service.add_device_token(session, current_user, payload.device_token)
122
- # return {"status": "ok", "tokens": tokens}
123
-
124
-
125
-
126
-
127
- # @router.get("/login")
128
- # def google_login(state: str | None = Query(None)):
129
- # return RedirectResponse(build_auth_url(state))
130
-
131
-
132
- # @router.get("/callback")
133
- # async def google_callback(code: str | None = None, state: str | None = None):
134
- # if not code:
135
- # raise HTTPException(400, "Missing code")
136
-
137
- # token_data = await exchange_code_for_tokens(code)
138
- # access_token = token_data["access_token"]
139
- # refresh_token = token_data.get("refresh_token")
140
-
141
- # # Get user info
142
- # async with httpx.AsyncClient() as client:
143
- # r = await client.get(
144
- # "https://www.googleapis.com/oauth2/v3/userinfo",
145
- # headers={"Authorization": f"Bearer {access_token}"},
146
- # )
147
- # userinfo = r.json()
148
-
149
- # google_user_id = userinfo["sub"]
150
- # user_email = userinfo["email"]
151
-
152
- # USER_TOKEN_STORE[google_user_id] = {
153
- # "access_token": access_token,
154
- # "refresh_token": refresh_token,
155
- # "email": user_email,
156
- # }
157
-
158
- # return JSONResponse(
159
- # {
160
- # "status": "ok",
161
- # "user_id": google_user_id,
162
- # "email": user_email,
163
- # "state": state,
164
- # }
165
- # )
166
-
167
-
168
- # @router.post("/send-mail")
169
- # async def send_mail(req: SendMailRequest):
170
- # return await send_email_service(req)
171
-
172
-
173
- # @router.get("/", response_model=BaseResponse)
174
- # async def get_assets(
175
- # user_id: str = Depends(get_current_user),
176
- # session: AsyncSession = Depends(get_async_session),
177
- # ):
178
- # assets = await list_user_assets(session, user_id)
179
-
180
- # data = {
181
- # "assets": [
182
- # {
183
- # "id": a.id,
184
- # "name": a.name,
185
- # "type": a.type,
186
- # "status": a.status,
187
- # }
188
- # for a in assets
189
- # ]
190
- # }
191
-
192
- # return {"code": 200, "data": data}
193
-
194
-
195
- # @router.put("/update-profile", response_model=BaseResponse)
196
- # async def update_profile(
197
- # payload: UpdateProfileRequest,
198
- # user_id: str = Depends(get_current_user),
199
- # session: AsyncSession = Depends(get_async_session),
200
- # ):
201
- # result = await update_user_profile(session, user_id, payload)
202
- # return {"code": 200, "data": result}
203
-
204
-
205
- # @router.get("/contacts", response_model=BaseResponse)
206
- # async def get_leave_contacts(
207
- # current_user=Depends(get_current_user),
208
- # session: AsyncSession = Depends(get_async_session),
209
- # ):
210
- # # get_current_user returns a STRING user_id
211
- # user_id = current_user
212
-
213
- # if not user_id:
214
- # raise HTTPException(status_code=400, detail="Invalid user token")
215
-
216
- # # 1) Get user's team
217
- # stmt = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
218
- # ut = (await session.exec(stmt)).first()
219
-
220
- # if not ut:
221
- # raise HTTPException(status_code=404, detail="User-Team mapping not found")
222
-
223
- # # 2) Get Team Lead role
224
- # lead_role = (
225
- # await session.exec(select(Roles).where(Roles.name == "Team Lead"))
226
- # ).first()
227
-
228
- # if not lead_role:
229
- # raise HTTPException(status_code=500, detail="Team Lead role not found")
230
-
231
- # # 3) Find Team Lead user in same team
232
- # lead_user = (
233
- # await session.exec(
234
- # select(Users)
235
- # .join(UserTeamsRole)
236
- # .where(UserTeamsRole.team_id == ut.team_id)
237
- # .where(UserTeamsRole.role_id == lead_role.id)
238
- # )
239
- # ).all()
240
-
241
- # if not lead_user:
242
- # raise HTTPException(status_code=404, detail="Team lead not found")
243
-
244
- # to_email = ", ".join([u.email_id for u in lead_user])
245
-
246
- # # 4) HR CC emails
247
- # hr_team = (await session.exec(select(Teams).where(Teams.name == "HR Team"))).first()
248
-
249
- # cc = []
250
- # if hr_team:
251
- # hr_users = (
252
- # await session.exec(
253
- # select(Users)
254
- # .join(UserTeamsRole)
255
- # .where(UserTeamsRole.team_id == hr_team.id)
256
- # )
257
- # ).all()
258
-
259
- # cc = [str(row.email_id) for row in hr_users]
260
-
261
- # return BaseResponse(code=200, message="success", data={"to": to_email, "cc": cc})
262
-
263
-
264
- # @router.post("/send", response_model=BaseResponse)
265
- # async def send_leave_email(
266
- # payload: dict,
267
- # background: BackgroundTasks,
268
- # current_user=Depends(get_current_user),
269
- # ):
270
- # from_email = payload.get("from_email")
271
- # to_email = payload.get("to")
272
- # cc = payload.get("cc", [])
273
- # subject = payload.get("subject")
274
- # body = payload.get("body")
275
-
276
- # if not subject or not body:
277
- # raise HTTPException(status_code=400, detail="Subject and body required")
278
-
279
- # # send in background so API returns fast
280
- # background.add_task(send_email, to_email, subject, body, cc, from_email)
281
-
282
- # return BaseResponse(code=200, message="Leave request sent", data=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.core.database import get_async_session
2
+ from src.auth.utils import get_current_user
3
+ from src.profile.models import Leave, LeaveType, LeaveStatus
4
+ from src.auth.schemas import BaseResponse
5
+ from sqlalchemy import desc
6
+ from sqlalchemy.ext.asyncio.session import AsyncSession
7
+ from fastapi.params import Depends
8
+ from src.core.models import Users, Teams, Roles, UserTeamsRole
9
+ from fastapi import APIRouter, Query, HTTPException, BackgroundTasks
10
+ from fastapi.responses import RedirectResponse, JSONResponse
11
+ from fastapi import APIRouter, Depends, HTTPException, status
12
+ from sqlmodel.ext.asyncio.session import AsyncSession
13
+ from src.auth.utils import get_current_user # adjust path
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+ from sqlmodel import select
17
+ import uuid
18
+ from src.core.models import Users
19
+ from src.profile.schemas import (
20
+ CreateLeaveRequest,
21
+ LeaveResponse,
22
+ ApproveRejectRequest,
23
+ LeaveStatus,
24
+ )
25
+ from src.profile.service import create_leave, mentor_decide_leave
 
 
 
 
 
 
 
26
 
27
 
28
  # router = APIRouter(prefix="/profile", tags=["Profile"])
29
 
30
 
31
+ @router.post("/request", response_model=LeaveResponse)
32
+ async def request_leave_route(
33
+ body: CreateLeaveRequest,
34
+ session: AsyncSession = Depends(get_async_session),
35
+ user_id: str = Depends(get_current_user),
36
+ ):
37
+ # convert user_id string -> UUID if needed
38
+ try:
39
+ user_uuid = uuid.UUID(user_id)
40
+ except ValueError:
41
+ raise HTTPException(status_code=400, detail="Invalid user id format")
42
+
43
+ leave = await create_leave(session, user_uuid, body)
44
+
45
+ return LeaveResponse(
46
+ id=str(leave.id),
47
+ leave_type=leave.leave_type,
48
+ from_date=leave.from_date,
49
+ to_date=leave.to_date,
50
+ days=leave.days,
51
+ reason=leave.reason,
52
+ status=leave.status,
53
+ mentor_id=str(leave.mentor_id),
54
+ lead_id=str(leave.lead_id),
55
+ )
56
+
57
+
58
+ @router.post("/{leave_id}/mentor-decision", response_model=LeaveResponse)
59
+ async def mentor_decision_route(
60
+ leave_id: str,
61
+ body: ApproveRejectRequest,
62
+ session: AsyncSession = Depends(get_async_session),
63
+ user_id: str = Depends(get_current_user),
64
+ ):
65
+ # validate leave_id + user_id UUIDs
66
+ try:
67
+ leave_uuid = uuid.UUID(leave_id)
68
+ mentor_uuid = uuid.UUID(user_id)
69
+ except ValueError:
70
+ raise HTTPException(status_code=400, detail="Invalid UUID")
71
+
72
+ # If rejected, comment must be provided
73
+ if body.status == LeaveStatus.REJECTED and not body.comment:
74
+ raise HTTPException(
75
+ status_code=400,
76
+ detail="Comment is required when rejecting leave",
77
+ )
78
+
79
+ try:
80
+ leave = await mentor_decide_leave(session, mentor_uuid, leave_uuid, body)
81
+ except PermissionError as e:
82
+ raise HTTPException(status_code=403, detail=str(e))
83
+ except ValueError as e:
84
+ raise HTTPException(status_code=404, detail=str(e))
85
+
86
+ return LeaveResponse(
87
+ id=str(leave.id),
88
+ leave_type=leave.leave_type,
89
+ from_date=leave.from_date,
90
+ to_date=leave.to_date,
91
+ days=leave.days,
92
+ reason=leave.reason,
93
+ status=leave.status,
94
+ mentor_id=str(leave.mentor_id),
95
+ lead_id=str(leave.lead_id),
96
+ )
97
+
98
+
99
+ SICK_LIMIT = 10
100
+ CASUAL_LIMIT = 10
101
+
102
+
103
+ @router.get("/balance")
104
+ async def get_leave_balance(
105
+ session: AsyncSession = Depends(get_async_session),
106
+ user_id: str = Depends(get_current_user),
107
+ ):
108
+ stmt = select(Leave).where(
109
+ Leave.user_id == user_id, Leave.status == LeaveStatus.APPROVED
110
+ )
111
+ results = (await session.exec(stmt)).all()
112
+
113
+ sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
114
+ casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
115
+
116
+ sick_remaining = SICK_LIMIT - sick_used
117
+ casual_remaining = CASUAL_LIMIT - casual_used
118
+
119
+ return {
120
+ "code": 200,
121
+ "message": "success",
122
+ "data": {
123
+ "sick_remaining": sick_remaining,
124
+ "casual_remaining": casual_remaining,
125
+ },
126
+ }
127
+
128
+
129
+ @router.get("/balance/{user_id}")
130
+ async def get_leave_balance_for_user(
131
+ user_id: str,
132
+ session: AsyncSession = Depends(get_async_session),
133
+ ):
134
+ stmt = select(Leave).where(
135
+ Leave.user_id == user_id, Leave.status == LeaveStatus.APPROVED
136
+ )
137
+ results = (await session.exec(stmt)).all()
138
+
139
+ sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
140
+ casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
141
+
142
+ sick_remaining = SICK_LIMIT - sick_used
143
+ casual_remaining = CASUAL_LIMIT - casual_used
144
+
145
+ return {
146
+ "code": 200,
147
+ "message": "success",
148
+ "data": {
149
+ "sick_remaining": sick_remaining,
150
+ "casual_remaining": casual_remaining,
151
+ },
152
+ }
153
+
154
+
155
+ @router.get("/notifications")
156
+ async def list_notifications(
157
+ session: AsyncSession = Depends(get_async_session),
158
+ user_id: str = Depends(get_current_user),
159
+ ):
160
+ from src.profile.models import Leave
161
+
162
+ stmt = (
163
+ select(Leave)
164
+ .where(
165
+ (Leave.user_id == user_id)
166
+ | (Leave.mentor_id == user_id)
167
+ | (Leave.lead_id == user_id)
168
+ )
169
+ .order_by(desc(Leave.updated_at))
170
+ )
171
+
172
+ results = (await session.exec(stmt)).all()
173
+
174
+ notifications = []
175
+
176
+ for leave in results:
177
+ if leave.user_id == user_id:
178
+ # user = leave owner
179
+ title = f"Your leave was {leave.status}"
180
+ body = f"{leave.leave_type} from {leave.from_date} to {leave.to_date}"
181
+ elif leave.mentor_id == user_id:
182
+ # mentor receives new leave request
183
+ title = "New Leave Request"
184
+ body = f"{leave.leave_type} requested by user"
185
+ elif leave.lead_id == user_id:
186
+ # lead receives updates
187
+ title = f"Leave {leave.status}"
188
+ body = f"{leave.leave_type} for user updated"
189
+ else:
190
+ title = "Leave Update"
191
+ body = leave.reason or ""
192
+
193
+ notifications.append(
194
+ {
195
+ "id": str(leave.id),
196
+ "mentor_id": str(leave.mentor_id),
197
+ "lead_id": str(leave.lead_id),
198
+ "title": title,
199
+ "body": body,
200
+ "type": leave.status,
201
+ "updated_at": leave.updated_at.isoformat(),
202
+ "leave_type": leave.leave_type,
203
+ "from_date": str(leave.from_date),
204
+ "to_date": str(leave.to_date),
205
+ "reject_reason": leave.reject_reason,
206
+ "reason": leave.reason,
207
+ }
208
+ )
209
+
210
+ return {"code": 200, "data": notifications}
211
+
212
+
213
+ @router.get("/leave/{leave_id}")
214
+ async def get_leave_details(
215
+ leave_id: str,
216
+ session: AsyncSession = Depends(get_async_session),
217
+ user_id: str = Depends(get_current_user),
218
+ ):
219
+ leave = await session.get(Leave, leave_id)
220
+
221
+ if not leave:
222
+ raise HTTPException(status_code=404, detail="Leave not found")
223
+
224
+ # Convert to JSON response
225
+ return {
226
+ "code": 200,
227
+ "data": {
228
+ "id": str(leave.id),
229
+ "user_id": str(leave.user_id),
230
+ "mentor_id": str(leave.mentor_id),
231
+ "lead_id": str(leave.lead_id),
232
+ "leave_type": leave.leave_type,
233
+ "from_date": str(leave.from_date),
234
+ "to_date": str(leave.to_date),
235
+ "days": leave.days,
236
+ "reason": leave.reason,
237
+ "status": leave.status,
238
+ "reject_reason": leave.reject_reason,
239
+ "updated_at": leave.updated_at.isoformat(),
240
+ },
241
+ }
242
+
243
+
244
+ @router.get("/mentor/pending")
245
+ async def mentor_pending_leaves(
246
+ session: AsyncSession = Depends(get_async_session),
247
+ mentor_id: str = Depends(get_current_user),
248
+ ):
249
+ stmt = select(Leave).where(
250
+ Leave.mentor_id == mentor_id,
251
+ Leave.status == LeaveStatus.PENDING,
252
+ )
253
+
254
+ rows = (await session.exec(stmt)).all()
255
+
256
+ return {
257
+ "code": 200,
258
+ "data": [
259
+ LeaveResponse(
260
+ id=str(r.id),
261
+ leave_type=r.leave_type,
262
+ from_date=r.from_date,
263
+ to_date=r.to_date,
264
+ days=r.days,
265
+ reason=r.reason,
266
+ status=r.status,
267
+ mentor_id=str(r.mentor_id),
268
+ lead_id=str(r.lead_id),
269
+ )
270
+ for r in rows
271
+ ],
272
+ }
273
+
274
+
275
+ @router.get("/contacts", response_model=BaseResponse)
276
+ async def get_leave_contacts(
277
+ current_user=Depends(get_current_user),
278
+ session: AsyncSession = Depends(get_async_session),
279
+ ):
280
+ # get_current_user returns a STRING user_id
281
+ user_id = current_user
282
+
283
+ if not user_id:
284
+ raise HTTPException(status_code=400, detail="Invalid user token")
285
+
286
+ # 1) Get user's team
287
+ stmt = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
288
+ ut = (await session.exec(stmt)).first()
289
+
290
+ if not ut:
291
+ raise HTTPException(status_code=404, detail="User-Team mapping not found")
292
+
293
+ # 2) Get Team Lead role
294
+ lead_role = (
295
+ await session.exec(select(Roles).where(Roles.name == "Team Lead"))
296
+ ).first()
297
+
298
+ if not lead_role:
299
+ raise HTTPException(status_code=500, detail="Team Lead role not found")
300
+
301
+ # 3) Find Team Lead user in same team
302
+ lead_user = (
303
+ await session.exec(
304
+ select(Users)
305
+ .join(UserTeamsRole)
306
+ .where(UserTeamsRole.team_id == ut.team_id)
307
+ .where(UserTeamsRole.role_id == lead_role.id)
308
+ )
309
+ ).all()
310
+
311
+ if not lead_user:
312
+ raise HTTPException(status_code=404, detail="Team lead not found")
313
+
314
+ to_email = ", ".join([u.email_id for u in lead_user])
315
+
316
+ # 4) HR CC emails
317
+ hr_team = (await session.exec(select(Teams).where(Teams.name == "HR Team"))).first()
318
+
319
+ cc = []
320
+ if hr_team:
321
+ hr_users = (
322
+ await session.exec(
323
+ select(Users)
324
+ .join(UserTeamsRole)
325
+ .where(UserTeamsRole.team_id == hr_team.id)
326
+ )
327
+ ).all()
328
+
329
+ cc = [str(row.email_id) for row in hr_users]
330
+
331
+ return BaseResponse(code=200, message="success", data={"to": to_email, "cc": cc})
332
+
333
+
334
+ @router.get("/details", response_model=BaseResponse)
335
+ async def get_profile_details(
336
+ current_user=Depends(get_current_user),
337
+ session: AsyncSession = Depends(get_async_session),
338
+ ):
339
+ user_id = current_user
340
+
341
+ # 1) Get the user
342
+ user = await session.get(Users, user_id)
343
+ if not user:
344
+ raise HTTPException(status_code=404, detail="User not found")
345
+
346
+ # 2) Get user's team mapping
347
+ user_team = (
348
+ await session.exec(
349
+ select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
350
+ )
351
+ ).first()
352
+
353
+ if not user_team:
354
+ raise HTTPException(status_code=404, detail="User does not belong to any team")
355
+
356
+ # 3) Get team name
357
+ team = await session.get(Teams, user_team.team_id)
358
+
359
+ # 4) Find mentor (team lead)
360
+ lead_role = (
361
+ await session.exec(select(Roles).where(Roles.name == "Mentor"))
362
+ ).first()
363
+
364
+ mentor_users = (
365
+ await session.exec(
366
+ select(Users)
367
+ .join(UserTeamsRole)
368
+ .where(UserTeamsRole.team_id == user_team.team_id)
369
+ .where(UserTeamsRole.role_id == lead_role.id)
370
+ )
371
+ ).all()
372
+
373
+ mentor_names = [u.user_name for u in mentor_users]
374
+ mentor_emails = [u.email_id for u in mentor_users]
375
+
376
+ return BaseResponse(
377
+ code=200,
378
+ message="success",
379
+ data={
380
+ "name": user.user_name,
381
+ "email": user.email_id,
382
+ "team_name": team.name,
383
+ "mentor_name": ", ".join(mentor_names),
384
+ "mentor_email": ", ".join(mentor_emails),
385
+ },
386
+ )
src/profile/schemas.py CHANGED
@@ -1,48 +1,53 @@
1
- from pydantic import BaseModel, EmailStr ,Field
2
- from typing import Optional,List
3
  import uuid
4
  from enum import Enum
5
  from datetime import date
 
 
 
 
 
 
 
6
 
7
 
8
- class ApplyLeaveRequest(BaseModel):
9
- leave_type: str
 
 
 
 
 
 
10
  from_date: date
11
  to_date: date
12
- reason: Optional[str] = None
 
 
13
 
14
  class ApproveRejectRequest(BaseModel):
15
- comment: Optional[str] = None
16
- reject_reason: Optional[str] = None # used for reject endpoint
 
17
 
18
  class LeaveResponse(BaseModel):
19
- id: uuid.UUID
20
- user_id: uuid.UUID
21
- mentor_id: uuid.UUID
22
- lead_id: uuid.UUID
23
- leave_type: str
24
  from_date: date
25
  to_date: date
26
  days: int
27
- reason: Optional[str]
28
- status: str
29
- approved_by: Optional[uuid.UUID]
30
- approved_at: Optional[str]
31
- reject_reason: Optional[str]
32
- comment: Optional[str]
33
-
34
- class BalanceResponse(BaseModel):
35
- leave_type: str
36
- limit: int
37
- used: int
38
- remaining: int
39
 
40
  class DeviceTokenIn(BaseModel):
41
  device_token: str
42
  device_type: Optional[str] = None
43
 
44
 
45
-
46
  class AssetStatus(str, Enum):
47
  ACTIVE = "Active"
48
  UNAVAILABLE = "Unavailable"
 
1
+ from pydantic import BaseModel, EmailStr, Field
2
+ from typing import Optional, List
3
  import uuid
4
  from enum import Enum
5
  from datetime import date
6
+ from enum import Enum
7
+
8
+
9
+ class LeaveType(str, Enum):
10
+ SICK = "Sick"
11
+ CASUAL = "Casual"
12
+ EMERGENCY = "Emergency"
13
 
14
 
15
+ class LeaveStatus(str, Enum):
16
+ APPROVED = "Approved"
17
+ REJECTED = "Rejected"
18
+ PENDING = "Pending"
19
+
20
+
21
+ class CreateLeaveRequest(BaseModel):
22
+ leave_type: LeaveType
23
  from_date: date
24
  to_date: date
25
+ days: int
26
+ reason: str
27
+
28
 
29
  class ApproveRejectRequest(BaseModel):
30
+ status: LeaveStatus # APPROVED / REJECTED
31
+ comment: Optional[str] = None # optional for approve, required for reject
32
+
33
 
34
  class LeaveResponse(BaseModel):
35
+ id: str
36
+ leave_type: LeaveType
 
 
 
37
  from_date: date
38
  to_date: date
39
  days: int
40
+ reason: str
41
+ status: LeaveStatus
42
+ mentor_id: str
43
+ lead_id: str
44
+
 
 
 
 
 
 
 
45
 
46
  class DeviceTokenIn(BaseModel):
47
  device_token: str
48
  device_type: Optional[str] = None
49
 
50
 
 
51
  class AssetStatus(str, Enum):
52
  ACTIVE = "Active"
53
  UNAVAILABLE = "Unavailable"
src/profile/service.py CHANGED
@@ -1,26 +1,24 @@
1
- # from src.profile.utils import build_raw_message, refresh_access_token
2
- # from src.profile.schemas import SendMailRequest
3
- # from src.core.models import Assets, Users
4
- # from datetime import datetime
5
- # import uuid
6
- # from fastapi import HTTPException
7
- # from passlib.context import CryptContext
8
- # from src.core.models import Users
9
- # import uuid
10
- # from typing import List
11
- # from sqlmodel import select
12
- # from sqlmodel.ext.asyncio.session import AsyncSession
13
- # import httpx
14
- # from src.core.config import settings
15
- # from typing import List, Optional
16
- # from sqlmodel import select
17
- # from sqlmodel.ext.asyncio.session import AsyncSession
18
- # from fastapi import HTTPException
19
- # from datetime import datetime
20
- # from src.profile.schemas import ApplyLeaveRequest, ApproveRejectRequest
21
- # from src.profile.utils import calculate_days, find_mentor_and_lead
22
- # from src.profile.utils import get_tokens_for_user, send_push_to_tokens
23
- # from src.core.config import settings
24
 
25
 
26
  # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -32,6 +30,151 @@
32
  # CASUAL_LIMIT = getattr(settings, "CASUAL_LEAVE_LIMIT", 10)
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # async def apply_leave(session: AsyncSession, user_id, payload: ApplyLeaveRequest):
36
  # # compute days
37
  # days = calculate_days(payload.from_date, payload.to_date)
 
1
+ from src.notifications.service import get_user_device_tokens
2
+ from src.profile.utils import build_raw_message, refresh_access_token
3
+ from src.profile.schemas import SendMailRequest
4
+ from src.core.models import Assets, Users, UserTeamsRole, Roles
5
+ from fastapi import HTTPException
6
+ from passlib.context import CryptContext
7
+ import httpx
8
+ from src.core.config import settings
9
+ from sqlmodel import select
10
+ from sqlmodel.ext.asyncio.session import AsyncSession
11
+ from datetime import datetime
12
+ import uuid
13
+ from typing import List
14
+ from src.profile.models import (
15
+ Leave,
16
+ UserDevices,
17
+ )
18
+ from src.profile.notify import send_leave_request_notification
19
+
20
+ from src.profile.schemas import CreateLeaveRequest, LeaveStatus, ApproveRejectRequest
21
+ from src.notifications.fcm import send_fcm
 
 
22
 
23
 
24
  # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
 
30
  # CASUAL_LIMIT = getattr(settings, "CASUAL_LEAVE_LIMIT", 10)
31
 
32
 
33
+ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
34
+ """
35
+ Find user's team, mentor and team lead in that team.
36
+ """
37
+ # 1) Get user's team mapping
38
+ user_team = (
39
+ await session.exec(
40
+ select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
41
+ )
42
+ ).first()
43
+
44
+ if not user_team:
45
+ raise ValueError("User has no team mapping")
46
+
47
+ # 2) Get Mentor role
48
+ mentor_role = (
49
+ await session.exec(select(Roles).where(Roles.name == "Mentor"))
50
+ ).first()
51
+ if not mentor_role:
52
+ raise ValueError("Mentor role not found")
53
+
54
+ # 3) Get Team Lead role
55
+ lead_role = (
56
+ await session.exec(select(Roles).where(Roles.name == "Team Lead"))
57
+ ).first()
58
+ if not lead_role:
59
+ raise ValueError("Team Lead role not found")
60
+
61
+ # 4) Find mentor in same team
62
+ mentor_user = (
63
+ await session.exec(
64
+ select(Users)
65
+ .join(UserTeamsRole, UserTeamsRole.user_id == Users.id)
66
+ .where(UserTeamsRole.team_id == user_team.team_id)
67
+ .where(UserTeamsRole.role_id == mentor_role.id)
68
+ )
69
+ ).first()
70
+
71
+ if not mentor_user:
72
+ raise ValueError("Mentor not found in user's team")
73
+
74
+ # 5) Find team lead in same team
75
+ lead_user = (
76
+ await session.exec(
77
+ select(Users)
78
+ .join(UserTeamsRole, UserTeamsRole.user_id == Users.id)
79
+ .where(UserTeamsRole.team_id == user_team.team_id)
80
+ .where(UserTeamsRole.role_id == lead_role.id)
81
+ )
82
+ ).first()
83
+
84
+ if not lead_user:
85
+ raise ValueError("Team Lead not found in user's team")
86
+
87
+ return mentor_user, lead_user
88
+
89
+
90
+ async def _get_tokens_for_users(
91
+ session: AsyncSession, user_ids: List[uuid.UUID]
92
+ ) -> List[str]:
93
+ """
94
+ Get all device tokens for all given users.
95
+ """
96
+ tokens: List[str] = []
97
+ for uid in user_ids:
98
+ rows = (
99
+ await session.exec(select(UserDevices).where(UserDevices.user_id == uid))
100
+ ).all()
101
+ for row in rows:
102
+ if row.device_token:
103
+ tokens.append(row.device_token)
104
+ return tokens
105
+
106
+
107
+ async def create_leave(session, user_id, body):
108
+ # Get the user
109
+ user = await session.get(Users, user_id)
110
+
111
+ # Get mentor + team lead
112
+ mentor_user, lead_user = await _get_team_roles(session, user_id)
113
+
114
+ leave = Leave(
115
+ user_id=user_id,
116
+ leave_type=body.leave_type,
117
+ from_date=body.from_date,
118
+ to_date=body.to_date,
119
+ reason=body.reason,
120
+ days=body.days,
121
+ mentor_id=mentor_user.id,
122
+ lead_id=lead_user.id,
123
+ )
124
+
125
+ session.add(leave)
126
+ await session.commit()
127
+ await session.refresh(leave)
128
+
129
+ # Send notification
130
+ await send_leave_request_notification(
131
+ session,
132
+ user,
133
+ leave,
134
+ leave.mentor_id,
135
+ leave.lead_id,
136
+ )
137
+
138
+ # return leave
139
+
140
+
141
+ async def mentor_decide_leave(session, mentor_id, leave_id, body):
142
+ leave = await session.get(Leave, leave_id)
143
+ if not leave:
144
+ raise ValueError("Leave not found")
145
+
146
+ mentor = await session.get(Users, mentor_id)
147
+ if not mentor:
148
+ raise ValueError("Mentor not found")
149
+
150
+ # Update leave status
151
+ leave.status = body.status
152
+ leave.updated_at = datetime.utcnow()
153
+
154
+ if body.status == LeaveStatus.REJECTED:
155
+ leave.reject_reason = body.comment
156
+
157
+ await session.commit()
158
+ await session.refresh(leave)
159
+
160
+ # 🔥 Send notification to USER
161
+ from src.profile.notify import send_leave_status_notification
162
+
163
+ await send_leave_status_notification(session, leave, mentor.user_name)
164
+
165
+ # 🔥 Send notification to TEAM LEAD also
166
+ tokens = await get_user_device_tokens(session, leave.lead_id)
167
+
168
+ await send_fcm(
169
+ tokens,
170
+ "Leave Update",
171
+ f"{leave.user_id} leave was {body.status.lower()}",
172
+ {"type": "leave_status"},
173
+ )
174
+
175
+ # return leave
176
+
177
+
178
  # async def apply_leave(session: AsyncSession, user_id, payload: ApplyLeaveRequest):
179
  # # compute days
180
  # days = calculate_days(payload.from_date, payload.to_date)