Hp137 commited on
Commit
fa5d0ef
·
1 Parent(s): a9cac13

feat:added notification/leave services

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
@@ -1,4 +1,4 @@
1
- # from src.profile.router import router as profile
2
  from fastapi import FastAPI
3
 
4
 
@@ -6,6 +6,8 @@ from src.auth.router import router as auth_router
6
  from src.chatbot.router import router as chatbot
7
  from src.core.database import init_db
8
  from src.home.router import router as home_router
 
 
9
 
10
  # from src.profile.router import router as profile
11
 
@@ -17,10 +19,12 @@ init_db()
17
 
18
  app.include_router(auth_router)
19
 
20
- # app.include_router(profile)
21
 
22
  app.include_router(chatbot)
23
 
 
 
24
 
25
  @app.get("/")
26
  def root():
 
1
+ from src.profile.router import router as profile
2
  from fastapi import FastAPI
3
 
4
 
 
6
  from src.chatbot.router import router as chatbot
7
  from src.core.database import init_db
8
  from src.home.router import router as home_router
9
+ from src.notifications.router import router as notifications_router
10
+
11
 
12
  # from src.profile.router import router as profile
13
 
 
19
 
20
  app.include_router(auth_router)
21
 
22
+ app.include_router(profile)
23
 
24
  app.include_router(chatbot)
25
 
26
+ app.include_router(notifications_router)
27
+
28
 
29
  @app.get("/")
30
  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,203 +1,275 @@
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
- @router.get("/login")
126
- def google_login(state: str | None = Query(None)):
127
- return RedirectResponse(build_auth_url(state))
128
 
 
 
129
 
130
- @router.get("/callback")
131
- async def google_callback(code: str | None = None, state: str | None = None):
132
- if not code:
133
- raise HTTPException(400, "Missing code")
 
 
 
 
134
 
135
- token_data = await exchange_code_for_tokens(code)
136
- access_token = token_data["access_token"]
137
- refresh_token = token_data.get("refresh_token")
138
 
139
- # Get user info
140
- async with httpx.AsyncClient() as client:
141
- r = await client.get(
142
- "https://www.googleapis.com/oauth2/v3/userinfo",
143
- headers={"Authorization": f"Bearer {access_token}"},
 
 
 
 
 
 
 
 
144
  )
145
- userinfo = r.json()
146
-
147
- google_user_id = userinfo["sub"]
148
- user_email = userinfo["email"]
149
-
150
- USER_TOKEN_STORE[google_user_id] = {
151
- "access_token": access_token,
152
- "refresh_token": refresh_token,
153
- "email": user_email,
154
- }
155
-
156
- return JSONResponse(
157
- {
158
- "status": "ok",
159
- "user_id": google_user_id,
160
- "email": user_email,
161
- "state": state,
162
- }
163
  )
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- @router.post("/send-mail")
167
- async def send_mail(req: SendMailRequest):
168
- return await send_email_service(req)
169
 
170
 
171
- @router.get("/", response_model=BaseResponse)
172
- async def get_assets(
173
- user_id: str = Depends(get_current_user),
174
  session: AsyncSession = Depends(get_async_session),
 
175
  ):
176
- assets = await list_user_assets(session, user_id)
177
-
178
- data = {
179
- "assets": [
180
- {
181
- "id": a.id,
182
- "name": a.name,
183
- "type": a.type,
184
- "status": a.status,
185
- }
186
- for a in assets
187
- ]
 
 
 
 
 
 
 
 
 
 
188
  }
189
 
190
- return {"code": 200, "data": data}
191
 
192
-
193
- @router.put("/update-profile", response_model=BaseResponse)
194
- async def update_profile(
195
- payload: UpdateProfileRequest,
196
- user_id: str = Depends(get_current_user),
197
  session: AsyncSession = Depends(get_async_session),
 
198
  ):
199
- result = await update_user_profile(session, user_id, payload)
200
- return {"code": 200, "data": result}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
 
203
  @router.get("/contacts", response_model=BaseResponse)
@@ -312,306 +384,3 @@ async def get_profile_details(
312
  "mentor_email": ", ".join(mentor_emails),
313
  },
314
  )
315
-
316
-
317
- @router.post("/send", response_model=BaseResponse)
318
- async def send_leave_email(
319
- payload: dict,
320
- background: BackgroundTasks,
321
- current_user=Depends(get_current_user),
322
- ):
323
- from_email = payload.get("from_email")
324
- to_email = payload.get("to")
325
- cc = payload.get("cc", [])
326
- subject = payload.get("subject")
327
- body = payload.get("body")
328
-
329
- if not subject or not body:
330
- raise HTTPException(status_code=400, detail="Subject and body required")
331
-
332
- # send in background so API returns fast
333
- background.add_task(send_email, to_email, subject, body, cc, from_email)
334
-
335
- return BaseResponse(code=200, message="Leave request sent", data=None)
336
- # from src.profile.service import send_email_service
337
- # from src.profile.schemas import SendMailRequest
338
- # from src.profile.utils import build_auth_url
339
- # from src.profile.utils import exchange_code_for_tokens
340
- # from src.profile.service import USER_TOKEN_STORE
341
- # from src.profile.utils import send_email
342
- # from src.profile.service import list_user_assets
343
- # from fastapi.routing import APIRouter
344
- # from src.core.database import get_async_session
345
- # from src.auth.utils import get_current_user
346
- # from src.auth.schemas import BaseResponse
347
- # from sqlalchemy.ext.asyncio.session import AsyncSession
348
- # from fastapi.params import Depends
349
- # from .schemas import UpdateProfileRequest
350
- # from src.profile.service import update_user_profile
351
- # from sqlmodel import select
352
- # from src.core.models import Users, Teams, Roles, UserTeamsRole
353
- # from fastapi import APIRouter, Query, HTTPException ,BackgroundTasks
354
- # from fastapi.responses import RedirectResponse, JSONResponse
355
- # import httpx
356
- # from fastapi import APIRouter, Depends, HTTPException, status
357
- # from sqlmodel.ext.asyncio.session import AsyncSession
358
- # from src.auth.utils import get_current_user # adjust path
359
- # from src.profile.schemas import (
360
- # ApplyLeaveRequest,
361
- # ApproveRejectRequest,
362
- # LeaveResponse,
363
- # BalanceResponse,
364
- # DeviceTokenIn,
365
- # )
366
- # from src.profile import service
367
- # from typing import List
368
-
369
-
370
- # router = APIRouter(prefix="/profile", tags=["Profile"])
371
-
372
-
373
- # @router.post("/apply", response_model=LeaveResponse)
374
- # async def apply_leave_endpoint(
375
- # payload: ApplyLeaveRequest,
376
- # session: AsyncSession = Depends(get_async_session),
377
- # current_user=Depends(get_current_user),
378
- # ):
379
- # user_id = current_user
380
- # leave = await service.apply_leave(session, user_id, payload)
381
- # return leave
382
-
383
-
384
- # @router.get("/pending", response_model=List[LeaveResponse])
385
- # async def pending_leaves(
386
- # session: AsyncSession = Depends(get_async_session),
387
- # current_user=Depends(get_current_user),
388
- # ):
389
- # user_id = current_user
390
- # leaves = await service.get_pending_leaves_for_approver(session, user_id)
391
- # return leaves
392
-
393
-
394
- # @router.get("/my", response_model=List[LeaveResponse])
395
- # async def my_leaves(
396
- # session: AsyncSession = Depends(get_async_session),
397
- # current_user=Depends(get_current_user),
398
- # ):
399
- # leaves = await service.get_my_leaves(session, current_user)
400
- # return leaves
401
-
402
-
403
- # @router.get("/team", response_model=List[LeaveResponse])
404
- # async def team_leaves(
405
- # session: AsyncSession = Depends(get_async_session),
406
- # current_user=Depends(get_current_user),
407
- # ):
408
- # leaves = await service.get_team_leaves(session, current_user)
409
- # return leaves
410
-
411
-
412
- # @router.post("/{leave_id}/approve", response_model=LeaveResponse)
413
- # async def approve_leave_endpoint(
414
- # leave_id: str,
415
- # payload: ApproveRejectRequest,
416
- # session: AsyncSession = Depends(get_async_session),
417
- # current_user=Depends(get_current_user),
418
- # ):
419
- # leave = await service.approve_leave(
420
- # session, current_user, leave_id, comment=payload.comment
421
- # )
422
- # return leave
423
-
424
-
425
- # @router.post("/{leave_id}/reject", response_model=LeaveResponse)
426
- # async def reject_leave_endpoint(
427
- # leave_id: str,
428
- # payload: ApproveRejectRequest,
429
- # session: AsyncSession = Depends(get_async_session),
430
- # current_user=Depends(get_current_user),
431
- # ):
432
- # leave = await service.reject_leave(
433
- # session,
434
- # current_user,
435
- # leave_id,
436
- # reject_reason=payload.reject_reason,
437
- # comment=payload.comment,
438
- # )
439
- # return leave
440
-
441
-
442
- # @router.get("/balance", response_model=List[BalanceResponse])
443
- # async def get_balance(
444
- # session: AsyncSession = Depends(get_async_session),
445
- # current_user=Depends(get_current_user),
446
- # ):
447
- # return await service.get_leave_balance(session, current_user)
448
-
449
-
450
- # @router.post("/device-token")
451
- # async def save_device_token(
452
- # payload: DeviceTokenIn,
453
- # session: AsyncSession = Depends(get_async_session),
454
- # current_user=Depends(get_current_user),
455
- # ):
456
- # tokens = await service.add_device_token(session, current_user, payload.device_token)
457
- # return {"status": "ok", "tokens": tokens}
458
-
459
-
460
-
461
-
462
- # @router.get("/login")
463
- # def google_login(state: str | None = Query(None)):
464
- # return RedirectResponse(build_auth_url(state))
465
-
466
-
467
- # @router.get("/callback")
468
- # async def google_callback(code: str | None = None, state: str | None = None):
469
- # if not code:
470
- # raise HTTPException(400, "Missing code")
471
-
472
- # token_data = await exchange_code_for_tokens(code)
473
- # access_token = token_data["access_token"]
474
- # refresh_token = token_data.get("refresh_token")
475
-
476
- # # Get user info
477
- # async with httpx.AsyncClient() as client:
478
- # r = await client.get(
479
- # "https://www.googleapis.com/oauth2/v3/userinfo",
480
- # headers={"Authorization": f"Bearer {access_token}"},
481
- # )
482
- # userinfo = r.json()
483
-
484
- # google_user_id = userinfo["sub"]
485
- # user_email = userinfo["email"]
486
-
487
- # USER_TOKEN_STORE[google_user_id] = {
488
- # "access_token": access_token,
489
- # "refresh_token": refresh_token,
490
- # "email": user_email,
491
- # }
492
-
493
- # return JSONResponse(
494
- # {
495
- # "status": "ok",
496
- # "user_id": google_user_id,
497
- # "email": user_email,
498
- # "state": state,
499
- # }
500
- # )
501
-
502
-
503
- # @router.post("/send-mail")
504
- # async def send_mail(req: SendMailRequest):
505
- # return await send_email_service(req)
506
-
507
-
508
- # @router.get("/", response_model=BaseResponse)
509
- # async def get_assets(
510
- # user_id: str = Depends(get_current_user),
511
- # session: AsyncSession = Depends(get_async_session),
512
- # ):
513
- # assets = await list_user_assets(session, user_id)
514
-
515
- # data = {
516
- # "assets": [
517
- # {
518
- # "id": a.id,
519
- # "name": a.name,
520
- # "type": a.type,
521
- # "status": a.status,
522
- # }
523
- # for a in assets
524
- # ]
525
- # }
526
-
527
- # return {"code": 200, "data": data}
528
-
529
-
530
- # @router.put("/update-profile", response_model=BaseResponse)
531
- # async def update_profile(
532
- # payload: UpdateProfileRequest,
533
- # user_id: str = Depends(get_current_user),
534
- # session: AsyncSession = Depends(get_async_session),
535
- # ):
536
- # result = await update_user_profile(session, user_id, payload)
537
- # return {"code": 200, "data": result}
538
-
539
-
540
- # @router.get("/contacts", response_model=BaseResponse)
541
- # async def get_leave_contacts(
542
- # current_user=Depends(get_current_user),
543
- # session: AsyncSession = Depends(get_async_session),
544
- # ):
545
- # # get_current_user returns a STRING user_id
546
- # user_id = current_user
547
-
548
- # if not user_id:
549
- # raise HTTPException(status_code=400, detail="Invalid user token")
550
-
551
- # # 1) Get user's team
552
- # stmt = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
553
- # ut = (await session.exec(stmt)).first()
554
-
555
- # if not ut:
556
- # raise HTTPException(status_code=404, detail="User-Team mapping not found")
557
-
558
- # # 2) Get Team Lead role
559
- # lead_role = (
560
- # await session.exec(select(Roles).where(Roles.name == "Team Lead"))
561
- # ).first()
562
-
563
- # if not lead_role:
564
- # raise HTTPException(status_code=500, detail="Team Lead role not found")
565
-
566
- # # 3) Find Team Lead user in same team
567
- # lead_user = (
568
- # await session.exec(
569
- # select(Users)
570
- # .join(UserTeamsRole)
571
- # .where(UserTeamsRole.team_id == ut.team_id)
572
- # .where(UserTeamsRole.role_id == lead_role.id)
573
- # )
574
- # ).all()
575
-
576
- # if not lead_user:
577
- # raise HTTPException(status_code=404, detail="Team lead not found")
578
-
579
- # to_email = ", ".join([u.email_id for u in lead_user])
580
-
581
- # # 4) HR CC emails
582
- # hr_team = (await session.exec(select(Teams).where(Teams.name == "HR Team"))).first()
583
-
584
- # cc = []
585
- # if hr_team:
586
- # hr_users = (
587
- # await session.exec(
588
- # select(Users)
589
- # .join(UserTeamsRole)
590
- # .where(UserTeamsRole.team_id == hr_team.id)
591
- # )
592
- # ).all()
593
-
594
- # cc = [str(row.email_id) for row in hr_users]
595
-
596
- # return BaseResponse(code=200, message="success", data={"to": to_email, "cc": cc})
597
-
598
-
599
- # @router.post("/send", response_model=BaseResponse)
600
- # async def send_leave_email(
601
- # payload: dict,
602
- # background: BackgroundTasks,
603
- # current_user=Depends(get_current_user),
604
- # ):
605
- # from_email = payload.get("from_email")
606
- # to_email = payload.get("to")
607
- # cc = payload.get("cc", [])
608
- # subject = payload.get("subject")
609
- # body = payload.get("body")
610
-
611
- # if not subject or not body:
612
- # raise HTTPException(status_code=400, detail="Subject and body required")
613
-
614
- # # send in background so API returns fast
615
- # background.add_task(send_email, to_email, subject, body, cc, from_email)
616
-
617
- # 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)
 
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,35 +1,178 @@
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")
27
-
28
- # # src/profile/service.py
29
-
30
- # # Leave limits (you can move to config)
31
- # SICK_LIMIT = getattr(settings, "SICK_LEAVE_LIMIT", 10)
32
- # CASUAL_LIMIT = getattr(settings, "CASUAL_LEAVE_LIMIT", 10)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  # async def apply_leave(session: AsyncSession, user_id, payload: ApplyLeaveRequest):
@@ -234,25 +377,25 @@
234
  # return leave
235
 
236
 
237
- # async def add_device_token(session: AsyncSession, user_id, device_token: str):
238
- # """
239
- # Add FCM token to Users.device_tokens ARRAY.
240
- # Avoid duplicates.
241
- # """
242
 
243
- # # 1) Fetch user
244
- # user = await session.get(Users, user_id)
245
- # if not user:
246
- # raise HTTPException(404, "User not found")
247
 
248
- # # 2) If token not present -> add it
249
- # if device_token not in user.device_tokens:
250
- # user.device_tokens.append(device_token)
251
- # session.add(user)
252
- # await session.commit()
253
- # await session.refresh(user)
254
 
255
- # return user.device_tokens
256
 
257
 
258
  # async def get_leave_balance(session: AsyncSession, user_id) -> List[dict]:
@@ -278,106 +421,106 @@
278
  # ]
279
 
280
 
281
- # # In production, replace with DB storage
282
- # USER_TOKEN_STORE = {} # {google_user_id: {tokens}}
283
 
284
 
285
- # async def send_email_service(req: SendMailRequest):
286
- # record = USER_TOKEN_STORE.get(req.user_id)
287
- # if not record:
288
- # raise HTTPException(404, "User not logged in with Google OAuth")
289
 
290
- # access_token = record["access_token"]
291
- # refresh_token = record.get("refresh_token")
292
 
293
- # if not access_token and refresh_token:
294
- # new_tokens = await refresh_access_token(refresh_token)
295
- # access_token = new_tokens["access_token"]
296
- # record["access_token"] = access_token
297
 
298
- # if not access_token:
299
- # raise HTTPException(400, "Re-auth required")
300
 
301
- # raw = build_raw_message(
302
- # to_email=req.to,
303
- # subject=req.subject,
304
- # body=req.body,
305
- # from_name=req.from_name,
306
- # from_email=record["email"],
307
- # )
308
 
309
- # url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
310
- # payload = {"raw": raw}
311
 
312
- # async with httpx.AsyncClient() as client:
313
- # r = await client.post(
314
- # url, json=payload, headers={"Authorization": f"Bearer {access_token}"}
315
- # )
316
 
317
- # if r.status_code >= 400:
318
- # raise HTTPException(500, f"Gmail error: {r.text}")
319
 
320
- # return r.json()
321
 
322
 
323
- # async def update_user_profile(session, user_id: str, data):
324
- # user = await session.get(Users, uuid.UUID(user_id))
325
 
326
- # if not user:
327
- # raise HTTPException(status_code=404, detail="User not found")
328
 
329
- # # --- Update Name ---
330
- # if data.name:
331
- # user.user_name = data.name
332
 
333
- # # --- Update Email ---
334
- # if data.email:
335
- # user.email_id = data.email
336
 
337
- # # --- Update DOB ---
338
- # if data.dob:
339
- # try:
340
- # # Convert DD.MM.YYYY → Python date
341
- # parsed_date = datetime.strptime(data.dob, "%d.%m.%Y").date()
342
- # user.dob = parsed_date
343
- # except:
344
- # raise HTTPException(
345
- # status_code=400, detail="DOB must be in DD.MM.YYYY format"
346
- # )
347
 
348
- # # --- Update Address ---
349
- # if data.address:
350
- # user.address = data.address
351
 
352
- # # --- Change Password ---
353
- # if data.new_password:
354
- # if not data.current_password:
355
- # raise HTTPException(status_code=400, detail="Current password required")
356
 
357
- # # Verify old password
358
- # if not pwd_context.verify(data.current_password, user.password):
359
- # raise HTTPException(status_code=400, detail="Incorrect current password")
360
 
361
- # # Set new password
362
- # user.password = pwd_context.hash(data.new_password)
363
 
364
- # # Commit changes
365
- # await session.commit()
366
- # await session.refresh(user)
367
-
368
- # return {
369
- # "message": "Profile updated successfully",
370
- # "user": {
371
- # "id": str(user.id),
372
- # "name": user.user_name,
373
- # "email": user.email_id,
374
- # "dob": user.dob.isoformat() if user.dob else None,
375
- # "address": user.address,
376
- # "is_verified": user.is_verified,
377
- # },
378
- # }
379
 
 
 
 
 
 
 
 
 
 
 
 
380
 
381
- # async def list_user_assets(session: AsyncSession, user_id: str) -> List[Assets]:
382
- # q = await session.exec(select(Assets).where(Assets.user_id == uuid.UUID(user_id)))
383
- # return q.all()
 
 
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")
25
+
26
+ # src/profile/service.py
27
+
28
+ # Leave limits (you can move to config)
29
+ SICK_LIMIT = getattr(settings, "SICK_LEAVE_LIMIT", 10)
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):
 
377
  # return leave
378
 
379
 
380
+ async def add_device_token(session: AsyncSession, user_id, device_token: str):
381
+ """
382
+ Add FCM token to Users.device_tokens ARRAY.
383
+ Avoid duplicates.
384
+ """
385
 
386
+ # 1) Fetch user
387
+ user = await session.get(Users, user_id)
388
+ if not user:
389
+ raise HTTPException(404, "User not found")
390
 
391
+ # 2) If token not present -> add it
392
+ if device_token not in user.device_tokens:
393
+ user.device_tokens.append(device_token)
394
+ session.add(user)
395
+ await session.commit()
396
+ await session.refresh(user)
397
 
398
+ return user.device_tokens
399
 
400
 
401
  # async def get_leave_balance(session: AsyncSession, user_id) -> List[dict]:
 
421
  # ]
422
 
423
 
424
+ # In production, replace with DB storage
425
+ USER_TOKEN_STORE = {} # {google_user_id: {tokens}}
426
 
427
 
428
+ async def send_email_service(req: SendMailRequest):
429
+ record = USER_TOKEN_STORE.get(req.user_id)
430
+ if not record:
431
+ raise HTTPException(404, "User not logged in with Google OAuth")
432
 
433
+ access_token = record["access_token"]
434
+ refresh_token = record.get("refresh_token")
435
 
436
+ if not access_token and refresh_token:
437
+ new_tokens = await refresh_access_token(refresh_token)
438
+ access_token = new_tokens["access_token"]
439
+ record["access_token"] = access_token
440
 
441
+ if not access_token:
442
+ raise HTTPException(400, "Re-auth required")
443
 
444
+ raw = build_raw_message(
445
+ to_email=req.to,
446
+ subject=req.subject,
447
+ body=req.body,
448
+ from_name=req.from_name,
449
+ from_email=record["email"],
450
+ )
451
 
452
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
453
+ payload = {"raw": raw}
454
 
455
+ async with httpx.AsyncClient() as client:
456
+ r = await client.post(
457
+ url, json=payload, headers={"Authorization": f"Bearer {access_token}"}
458
+ )
459
 
460
+ if r.status_code >= 400:
461
+ raise HTTPException(500, f"Gmail error: {r.text}")
462
 
463
+ return r.json()
464
 
465
 
466
+ async def update_user_profile(session, user_id: str, data):
467
+ user = await session.get(Users, uuid.UUID(user_id))
468
 
469
+ if not user:
470
+ raise HTTPException(status_code=404, detail="User not found")
471
 
472
+ # --- Update Name ---
473
+ if data.name:
474
+ user.user_name = data.name
475
 
476
+ # --- Update Email ---
477
+ if data.email:
478
+ user.email_id = data.email
479
 
480
+ # --- Update DOB ---
481
+ if data.dob:
482
+ try:
483
+ # Convert DD.MM.YYYY → Python date
484
+ parsed_date = datetime.strptime(data.dob, "%d.%m.%Y").date()
485
+ user.dob = parsed_date
486
+ except:
487
+ raise HTTPException(
488
+ status_code=400, detail="DOB must be in DD.MM.YYYY format"
489
+ )
490
 
491
+ # --- Update Address ---
492
+ if data.address:
493
+ user.address = data.address
494
 
495
+ # --- Change Password ---
496
+ if data.new_password:
497
+ if not data.current_password:
498
+ raise HTTPException(status_code=400, detail="Current password required")
499
 
500
+ # Verify old password
501
+ if not pwd_context.verify(data.current_password, user.password):
502
+ raise HTTPException(status_code=400, detail="Incorrect current password")
503
 
504
+ # Set new password
505
+ user.password = pwd_context.hash(data.new_password)
506
 
507
+ # Commit changes
508
+ await session.commit()
509
+ await session.refresh(user)
 
 
 
 
 
 
 
 
 
 
 
 
510
 
511
+ return {
512
+ "message": "Profile updated successfully",
513
+ "user": {
514
+ "id": str(user.id),
515
+ "name": user.user_name,
516
+ "email": user.email_id,
517
+ "dob": user.dob.isoformat() if user.dob else None,
518
+ "address": user.address,
519
+ "is_verified": user.is_verified,
520
+ },
521
+ }
522
 
523
+
524
+ async def list_user_assets(session: AsyncSession, user_id: str) -> List[Assets]:
525
+ q = await session.exec(select(Assets).where(Assets.user_id == uuid.UUID(user_id)))
526
+ return q.all()