Hp137 commited on
Commit
5dc2d03
·
1 Parent(s): 22b7809

feat:added leave function

Browse files
alembic/versions/1eacc17f4c52_add_leaves_table_and_device_tokens_array.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add leaves table and device_tokens array
2
+
3
+ Revision ID: 1eacc17f4c52
4
+ Revises: dd61202db14f
5
+ Create Date: 2025-11-18 22:14:31.077909
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel.sql.sqltypes
13
+ from sqlalchemy.dialects import postgresql
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '1eacc17f4c52'
17
+ down_revision: Union[str, Sequence[str], None] = 'dd61202db14f'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.add_column('users', sa.Column('device_tokens', postgresql.ARRAY(sa.String()), nullable=True))
26
+ # ### end Alembic commands ###
27
+
28
+
29
+ def downgrade() -> None:
30
+ """Downgrade schema."""
31
+ # ### commands auto generated by Alembic - please adjust! ###
32
+ op.drop_column('users', 'device_tokens')
33
+ # ### end Alembic commands ###
alembic/versions/a3c79664f866_sync_models.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """sync models
2
+
3
+ Revision ID: a3c79664f866
4
+ Revises: 1eacc17f4c52
5
+ Create Date: 2025-11-18 22:23:01.757260
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ import sqlmodel.sql.sqltypes
13
+ from sqlalchemy.dialects import postgresql
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'a3c79664f866'
17
+ down_revision: Union[str, Sequence[str], None] = '1eacc17f4c52'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Upgrade schema."""
24
+ # ### commands auto generated by Alembic - please adjust! ###
25
+ op.add_column('users', sa.Column('device_tokens', postgresql.ARRAY(sa.String()), nullable=True))
26
+ # ### end Alembic commands ###
27
+
28
+
29
+ def downgrade() -> None:
30
+ """Downgrade schema."""
31
+ # ### commands auto generated by Alembic - please adjust! ###
32
+ op.drop_column('users', 'device_tokens')
33
+ # ### end Alembic commands ###
src/core/config.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from pydantic import PostgresDsn, computed_field
2
  from pydantic_settings import BaseSettings, SettingsConfigDict
3
 
@@ -37,6 +38,10 @@ class Settings(BaseSettings):
37
  GOOGLE_CLIENT_SECRET: str
38
  GOOGLE_REDIRECT_URI: str
39
 
 
 
 
 
40
  AUTH_BASE: str = "https://accounts.google.com/o/oauth2/v2/auth"
41
  TOKEN_URL: str = "https://oauth2.googleapis.com/token"
42
  GMAIL_SEND_SCOPE: str = "https://www.googleapis.com/auth/gmail.send"
@@ -57,4 +62,5 @@ class Settings(BaseSettings):
57
  env_file=".env", case_sensitive=False, env_file_encoding="utf-8"
58
  )
59
 
 
60
  settings = Settings()
 
1
+ from typing import Optional
2
  from pydantic import PostgresDsn, computed_field
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
4
 
 
38
  GOOGLE_CLIENT_SECRET: str
39
  GOOGLE_REDIRECT_URI: str
40
 
41
+ FCM_SERVER_KEY: Optional[str] = None
42
+ SICK_LEAVE_LIMIT: int = 10
43
+ CASUAL_LEAVE_LIMIT: int = 10
44
+
45
  AUTH_BASE: str = "https://accounts.google.com/o/oauth2/v2/auth"
46
  TOKEN_URL: str = "https://oauth2.googleapis.com/token"
47
  GMAIL_SEND_SCOPE: str = "https://www.googleapis.com/auth/gmail.send"
 
62
  env_file=".env", case_sensitive=False, env_file_encoding="utf-8"
63
  )
64
 
65
+
66
  settings = Settings()
src/core/models.py CHANGED
@@ -1,7 +1,11 @@
 
 
1
  import uuid
2
  from datetime import date, datetime
3
  from enum import Enum
4
  from typing import List, Optional
 
 
5
 
6
  from sqlalchemy import CheckConstraint, UniqueConstraint
7
  from sqlmodel import Field, Relationship, SQLModel
 
1
+ from sqlalchemy.dialects.postgresql import ARRAY
2
+ from sqlalchemy import Column, String
3
  import uuid
4
  from datetime import date, datetime
5
  from enum import Enum
6
  from typing import List, Optional
7
+ from src.profile.models import Leaves
8
+
9
 
10
  from sqlalchemy import CheckConstraint, UniqueConstraint
11
  from sqlmodel import Field, Relationship, SQLModel
src/main.py CHANGED
@@ -13,7 +13,7 @@ app = FastAPI(title="Yuvabe App API")
13
 
14
  app.include_router(home_router, prefix="/home", tags=["Home"])
15
 
16
- # init_db()
17
 
18
  app.include_router(auth_router)
19
 
 
13
 
14
  app.include_router(home_router, prefix="/home", tags=["Home"])
15
 
16
+ init_db()
17
 
18
  app.include_router(auth_router)
19
 
src/profile/models.py CHANGED
@@ -1,2 +1,39 @@
1
  import uuid
2
- import sqlmodel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import uuid
2
+ from datetime import date, datetime
3
+ from typing import Optional
4
+ from sqlmodel import SQLModel, Field
5
+ from enum import Enum
6
+
7
+
8
+ class LeaveStatus(str, Enum):
9
+ PENDING = "PENDING"
10
+ APPROVED = "APPROVED"
11
+ REJECTED = "REJECTED"
12
+
13
+
14
+ class Leaves(SQLModel, table=True):
15
+ __tablename__ = "leaves"
16
+
17
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
18
+
19
+ # Foreign keys (users table)
20
+ user_id: uuid.UUID = Field(foreign_key="users.id", nullable=False)
21
+ mentor_id: uuid.UUID = Field(foreign_key="users.id", nullable=False)
22
+ lead_id: uuid.UUID = Field(foreign_key="users.id", nullable=False)
23
+
24
+ leave_type: str = Field(nullable=False)
25
+ from_date: date = Field(nullable=False)
26
+ to_date: date = Field(nullable=False)
27
+ days: int = Field(nullable=False)
28
+ reason: Optional[str] = None
29
+
30
+ status: LeaveStatus = Field(default=LeaveStatus.PENDING)
31
+
32
+ approved_by: Optional[uuid.UUID] = Field(foreign_key="users.id", default=None)
33
+ approved_at: Optional[datetime] = None
34
+ reject_reason: Optional[str] = None
35
+
36
+ comment: Optional[str] = None
37
+
38
+ created_at: datetime = Field(default_factory=datetime.now)
39
+ updated_at: datetime = Field(default_factory=datetime.now)
src/profile/router.py CHANGED
@@ -16,17 +16,113 @@ from src.profile.service import update_user_profile
16
  from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
17
  from sqlmodel import select
18
  from src.core.models import Users, Teams, Roles, UserTeamsRole
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  router = APIRouter(prefix="/profile", tags=["Profile"])
22
 
23
- # src/routers/gmail_oauth_router.py
24
- from fastapi import APIRouter, Query, HTTPException
25
- from fastapi.responses import RedirectResponse, JSONResponse
26
- import httpx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
- router = APIRouter(prefix="/gmail", tags=["Gmail OAuth"])
30
 
31
 
32
  @router.get("/login")
@@ -47,7 +143,7 @@ async def google_callback(code: str | None = None, state: str | None = None):
47
  async with httpx.AsyncClient() as client:
48
  r = await client.get(
49
  "https://www.googleapis.com/oauth2/v3/userinfo",
50
- headers={"Authorization": f"Bearer {access_token}"}
51
  )
52
  userinfo = r.json()
53
 
@@ -57,15 +153,17 @@ async def google_callback(code: str | None = None, state: str | None = None):
57
  USER_TOKEN_STORE[google_user_id] = {
58
  "access_token": access_token,
59
  "refresh_token": refresh_token,
60
- "email": user_email
61
  }
62
 
63
- return JSONResponse({
64
- "status": "ok",
65
- "user_id": google_user_id,
66
- "email": user_email,
67
- "state": state,
68
- })
 
 
69
 
70
 
71
  @router.post("/send-mail")
@@ -73,7 +171,6 @@ async def send_mail(req: SendMailRequest):
73
  return await send_email_service(req)
74
 
75
 
76
-
77
  @router.get("/", response_model=BaseResponse)
78
  async def get_assets(
79
  user_id: str = Depends(get_current_user),
 
16
  from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
17
  from sqlmodel import select
18
  from src.core.models import Users, Teams, Roles, UserTeamsRole
19
+ from fastapi import APIRouter, Query, HTTPException
20
+ from fastapi.responses import RedirectResponse, JSONResponse
21
+ import httpx
22
+ from fastapi import APIRouter, Depends, HTTPException, status
23
+ from sqlmodel.ext.asyncio.session import AsyncSession
24
+ from src.auth.utils import get_current_user # adjust path
25
+ from src.profile.schemas import (
26
+ ApplyLeaveRequest,
27
+ ApproveRejectRequest,
28
+ LeaveResponse,
29
+ BalanceResponse,
30
+ DeviceTokenIn,
31
+ )
32
+ from src.profile import service
33
+ from typing import List
34
 
35
 
36
  router = APIRouter(prefix="/profile", tags=["Profile"])
37
 
38
+
39
+ @router.post("/apply", response_model=LeaveResponse)
40
+ async def apply_leave_endpoint(
41
+ payload: ApplyLeaveRequest,
42
+ session: AsyncSession = Depends(get_async_session),
43
+ current_user=Depends(get_current_user),
44
+ ):
45
+ user_id = current_user
46
+ leave = await service.apply_leave(session, user_id, payload)
47
+ return leave
48
+
49
+
50
+ @router.get("/pending", response_model=List[LeaveResponse])
51
+ async def pending_leaves(
52
+ session: AsyncSession = Depends(get_async_session),
53
+ current_user=Depends(get_current_user),
54
+ ):
55
+ user_id = current_user
56
+ leaves = await service.get_pending_leaves_for_approver(session, user_id)
57
+ return leaves
58
+
59
+
60
+ @router.get("/my", response_model=List[LeaveResponse])
61
+ async def my_leaves(
62
+ session: AsyncSession = Depends(get_async_session),
63
+ current_user=Depends(get_current_user),
64
+ ):
65
+ leaves = await service.get_my_leaves(session, current_user)
66
+ return leaves
67
+
68
+
69
+ @router.get("/team", response_model=List[LeaveResponse])
70
+ async def team_leaves(
71
+ session: AsyncSession = Depends(get_async_session),
72
+ current_user=Depends(get_current_user),
73
+ ):
74
+ leaves = await service.get_team_leaves(session, current_user)
75
+ return leaves
76
+
77
+
78
+ @router.post("/{leave_id}/approve", response_model=LeaveResponse)
79
+ async def approve_leave_endpoint(
80
+ leave_id: str,
81
+ payload: ApproveRejectRequest,
82
+ session: AsyncSession = Depends(get_async_session),
83
+ current_user=Depends(get_current_user),
84
+ ):
85
+ leave = await service.approve_leave(
86
+ session, current_user, leave_id, comment=payload.comment
87
+ )
88
+ return leave
89
+
90
+
91
+ @router.post("/{leave_id}/reject", response_model=LeaveResponse)
92
+ async def reject_leave_endpoint(
93
+ leave_id: str,
94
+ payload: ApproveRejectRequest,
95
+ session: AsyncSession = Depends(get_async_session),
96
+ current_user=Depends(get_current_user),
97
+ ):
98
+ leave = await service.reject_leave(
99
+ session,
100
+ current_user,
101
+ leave_id,
102
+ reject_reason=payload.reject_reason,
103
+ comment=payload.comment,
104
+ )
105
+ return leave
106
+
107
+
108
+ @router.get("/balance", response_model=List[BalanceResponse])
109
+ async def get_balance(
110
+ session: AsyncSession = Depends(get_async_session),
111
+ current_user=Depends(get_current_user),
112
+ ):
113
+ return await service.get_leave_balance(session, current_user)
114
+
115
+
116
+ @router.post("/device-token")
117
+ async def save_device_token(
118
+ payload: DeviceTokenIn,
119
+ session: AsyncSession = Depends(get_async_session),
120
+ current_user=Depends(get_current_user),
121
+ ):
122
+ tokens = await service.add_device_token(session, current_user, payload.device_token)
123
+ return {"status": "ok", "tokens": tokens}
124
 
125
 
 
126
 
127
 
128
  @router.get("/login")
 
143
  async with httpx.AsyncClient() as client:
144
  r = await client.get(
145
  "https://www.googleapis.com/oauth2/v3/userinfo",
146
+ headers={"Authorization": f"Bearer {access_token}"},
147
  )
148
  userinfo = r.json()
149
 
 
153
  USER_TOKEN_STORE[google_user_id] = {
154
  "access_token": access_token,
155
  "refresh_token": refresh_token,
156
+ "email": user_email,
157
  }
158
 
159
+ return JSONResponse(
160
+ {
161
+ "status": "ok",
162
+ "user_id": google_user_id,
163
+ "email": user_email,
164
+ "state": state,
165
+ }
166
+ )
167
 
168
 
169
  @router.post("/send-mail")
 
171
  return await send_email_service(req)
172
 
173
 
 
174
  @router.get("/", response_model=BaseResponse)
175
  async def get_assets(
176
  user_id: str = Depends(get_current_user),
src/profile/schemas.py CHANGED
@@ -1,7 +1,46 @@
1
- from pydantic import BaseModel, EmailStr
2
- from typing import Optional
3
  import uuid
4
  from enum import Enum
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  class AssetStatus(str, Enum):
 
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):
src/profile/service.py CHANGED
@@ -1,8 +1,6 @@
1
- from src.profile.utils import build_raw_message
2
- from src.profile.utils import refresh_access_token
3
  from src.profile.schemas import SendMailRequest
4
- from src.core.models import Assets
5
- from ast import List
6
  from datetime import datetime
7
  import uuid
8
  from fastapi import HTTPException
@@ -12,13 +10,274 @@ import uuid
12
  from typing import List
13
  from sqlmodel import select
14
  from sqlmodel.ext.asyncio.session import AsyncSession
15
- from src.core.models import Assets
16
  import httpx
17
  from src.core.config import settings
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  # In production, replace with DB storage
24
  USER_TOKEN_STORE = {} # {google_user_id: {tokens}}
 
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
 
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.models import Leaves
21
+ from src.profile.schemas import ApplyLeaveRequest, ApproveRejectRequest
22
+ from src.profile.utils import calculate_days, find_mentor_and_lead
23
+ from src.profile.utils import get_tokens_for_user, send_push_to_tokens
24
+ from src.core.config import settings
25
 
26
 
27
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
28
 
29
+ # src/profile/service.py
30
+
31
+ # Leave limits (you can move to config)
32
+ SICK_LIMIT = getattr(settings, "SICK_LEAVE_LIMIT", 10)
33
+ CASUAL_LIMIT = getattr(settings, "CASUAL_LEAVE_LIMIT", 10)
34
+
35
+
36
+ async def apply_leave(session: AsyncSession, user_id, payload: ApplyLeaveRequest):
37
+ # compute days
38
+ days = calculate_days(payload.from_date, payload.to_date)
39
+ if days <= 0:
40
+ raise HTTPException(status_code=400, detail="Invalid date range")
41
+
42
+ # find mentor and lead
43
+ mentor, lead = await find_mentor_and_lead(session, user_id)
44
+ if not mentor or not lead:
45
+ raise HTTPException(status_code=400, detail="Mentor or Lead not found for user")
46
+
47
+ # check remaining balance
48
+ limit = SICK_LIMIT if payload.leave_type.lower() == "sick" else CASUAL_LIMIT
49
+ # sum used days for this leave_type
50
+ q = select(Leaves).where(
51
+ Leaves.user_id == user_id,
52
+ Leaves.leave_type.ilike(payload.leave_type),
53
+ Leaves.status == "APPROVED",
54
+ )
55
+ rows = (await session.exec(q)).all()
56
+ used = sum(r.days for r in rows) if rows else 0
57
+ remaining = limit - used
58
+ if days > remaining:
59
+ raise HTTPException(
60
+ status_code=400,
61
+ detail=f"Insufficient {payload.leave_type} balance. Remaining {remaining}",
62
+ )
63
+
64
+ leave = Leaves(
65
+ user_id=user_id,
66
+ mentor_id=mentor.id,
67
+ lead_id=lead.id,
68
+ leave_type=payload.leave_type,
69
+ from_date=payload.from_date,
70
+ to_date=payload.to_date,
71
+ days=days,
72
+ reason=payload.reason,
73
+ status="PENDING",
74
+ )
75
+ session.add(leave)
76
+ await session.commit()
77
+ await session.refresh(leave)
78
+
79
+ # push notifications to mentor & lead
80
+ title = "New Leave Request"
81
+ body = f"{user_id} applied {payload.leave_type} leave ({days} days)."
82
+ mentor_tokens = await get_tokens_for_user(session, mentor.id)
83
+ lead_tokens = await get_tokens_for_user(session, lead.id)
84
+ await send_push_to_tokens(
85
+ mentor_tokens,
86
+ title,
87
+ body,
88
+ data={"leave_id": str(leave.id), "action": "leave_request"},
89
+ )
90
+ await send_push_to_tokens(
91
+ lead_tokens,
92
+ title,
93
+ body,
94
+ data={"leave_id": str(leave.id), "action": "leave_request"},
95
+ )
96
+
97
+ return leave
98
+
99
+
100
+ async def get_pending_leaves_for_approver(
101
+ session: AsyncSession, approver_user_id
102
+ ) -> List[Leaves]:
103
+ # returns pending leaves where mentor_id == approver OR lead_id == approver
104
+ stmt = select(Leaves).where(
105
+ (Leaves.mentor_id == approver_user_id) | (Leaves.lead_id == approver_user_id),
106
+ Leaves.status == "PENDING",
107
+ )
108
+ return (await session.exec(stmt)).all()
109
+
110
+
111
+ async def get_my_leaves(session: AsyncSession, user_id) -> List[Leaves]:
112
+ stmt = (
113
+ select(Leaves)
114
+ .where(Leaves.user_id == user_id)
115
+ .order_by(Leaves.created_at.desc())
116
+ )
117
+ return (await session.exec(stmt)).all()
118
+
119
+
120
+ async def get_team_leaves(session: AsyncSession, lead_user_id) -> List[Leaves]:
121
+ # lead can view leaves where lead_id == lead_user_id
122
+ stmt = (
123
+ select(Leaves)
124
+ .where(Leaves.lead_id == lead_user_id)
125
+ .order_by(Leaves.created_at.desc())
126
+ )
127
+ return (await session.exec(stmt)).all()
128
+
129
+
130
+ async def approve_leave(
131
+ session: AsyncSession, approver_id, leave_id: str, comment: Optional[str] = None
132
+ ):
133
+ # transaction-safe update
134
+ async with session.begin():
135
+ stmt = select(Leaves).where(Leaves.id == leave_id).with_for_update()
136
+ leave = (await session.exec(stmt)).one_or_none()
137
+ if not leave:
138
+ raise HTTPException(404, "Leave not found")
139
+ if leave.status != "PENDING":
140
+ raise HTTPException(400, "Leave is not pending")
141
+
142
+ # optional: verify approver is mentor or lead for this leave
143
+ if str(approver_id) not in (str(leave.mentor_id), str(leave.lead_id)):
144
+ # you might want to check roles more thoroughly
145
+ raise HTTPException(403, "Not authorized to approve this leave")
146
+
147
+ # check balance again before approving
148
+ # compute limit and used
149
+ limit = SICK_LIMIT if leave.leave_type.lower() == "sick" else CASUAL_LIMIT
150
+ q = select(Leaves).where(
151
+ Leaves.user_id == leave.user_id,
152
+ Leaves.leave_type.ilike(leave.leave_type),
153
+ Leaves.status == "APPROVED",
154
+ )
155
+ approved_rows = (await session.exec(q)).all()
156
+ used = sum(r.days for r in approved_rows) if approved_rows else 0
157
+ if used + leave.days > limit:
158
+ raise HTTPException(400, "Insufficient balance at approval time")
159
+
160
+ # update
161
+ leave.status = "APPROVED"
162
+ leave.approved_by = approver_id
163
+ leave.approved_at = datetime.utcnow()
164
+ if comment:
165
+ leave.comment = comment
166
+
167
+ session.add(leave)
168
+ # commit done by context manager
169
+
170
+ # send push notification to member and lead
171
+ title = "Leave Approved"
172
+ body = f"Your leave ({leave.leave_type}) has been approved."
173
+ member_tokens = await get_tokens_for_user(session, leave.user_id)
174
+ lead_tokens = await get_tokens_for_user(session, leave.lead_id)
175
+ await send_push_to_tokens(
176
+ member_tokens,
177
+ title,
178
+ body,
179
+ data={"leave_id": str(leave.id), "action": "leave_approved"},
180
+ )
181
+ await send_push_to_tokens(
182
+ lead_tokens,
183
+ title,
184
+ body,
185
+ data={"leave_id": str(leave.id), "action": "leave_approved"},
186
+ )
187
+
188
+ return leave
189
+
190
+
191
+ async def reject_leave(
192
+ session: AsyncSession,
193
+ approver_id,
194
+ leave_id: str,
195
+ reject_reason: Optional[str] = None,
196
+ comment: Optional[str] = None,
197
+ ):
198
+ async with session.begin():
199
+ stmt = select(Leaves).where(Leaves.id == leave_id).with_for_update()
200
+ leave = (await session.exec(stmt)).one_or_none()
201
+ if not leave:
202
+ raise HTTPException(404, "Leave not found")
203
+ if leave.status != "PENDING":
204
+ raise HTTPException(400, "Leave is not pending")
205
+
206
+ if str(approver_id) not in (str(leave.mentor_id), str(leave.lead_id)):
207
+ raise HTTPException(403, "Not authorized to reject this leave")
208
+
209
+ leave.status = "REJECTED"
210
+ leave.approved_by = approver_id
211
+ leave.approved_at = datetime.utcnow()
212
+ leave.reject_reason = reject_reason
213
+ if comment:
214
+ leave.comment = comment
215
+ session.add(leave)
216
+
217
+ # push to member + lead
218
+ title = "Leave Rejected"
219
+ body = f"Your leave ({leave.leave_type}) has been rejected. Reason: {leave.reject_reason or 'N/A'}"
220
+ member_tokens = await get_tokens_for_user(session, leave.user_id)
221
+ lead_tokens = await get_tokens_for_user(session, leave.lead_id)
222
+ await send_push_to_tokens(
223
+ member_tokens,
224
+ title,
225
+ body,
226
+ data={"leave_id": str(leave.id), "action": "leave_rejected"},
227
+ )
228
+ await send_push_to_tokens(
229
+ lead_tokens,
230
+ title,
231
+ body,
232
+ data={"leave_id": str(leave.id), "action": "leave_rejected"},
233
+ )
234
+
235
+ return leave
236
+
237
+
238
+ async def add_device_token(session: AsyncSession, user_id, device_token: str):
239
+ """
240
+ Add FCM token to Users.device_tokens ARRAY.
241
+ Avoid duplicates.
242
+ """
243
+
244
+ # 1) Fetch user
245
+ user = await session.get(Users, user_id)
246
+ if not user:
247
+ raise HTTPException(404, "User not found")
248
+
249
+ # 2) If token not present -> add it
250
+ if device_token not in user.device_tokens:
251
+ user.device_tokens.append(device_token)
252
+ session.add(user)
253
+ await session.commit()
254
+ await session.refresh(user)
255
+
256
+ return user.device_tokens
257
+
258
+
259
+ async def get_leave_balance(session: AsyncSession, user_id) -> List[dict]:
260
+ # compute used for each leave_type and return
261
+ # using constants SICK_LIMIT and CASUAL_LIMIT
262
+ stmt = select(Leaves).where(Leaves.user_id == user_id, Leaves.status == "APPROVED")
263
+ rows = (await session.exec(stmt)).all()
264
+ used_sick = sum(r.days for r in rows if r.leave_type.lower() == "sick")
265
+ used_casual = sum(r.days for r in rows if r.leave_type.lower() == "casual")
266
+ return [
267
+ {
268
+ "leave_type": "Sick",
269
+ "limit": SICK_LIMIT,
270
+ "used": used_sick,
271
+ "remaining": max(0, SICK_LIMIT - used_sick),
272
+ },
273
+ {
274
+ "leave_type": "Casual",
275
+ "limit": CASUAL_LIMIT,
276
+ "used": used_casual,
277
+ "remaining": max(0, CASUAL_LIMIT - used_casual),
278
+ },
279
+ ]
280
+
281
 
282
  # In production, replace with DB storage
283
  USER_TOKEN_STORE = {} # {google_user_id: {tokens}}
src/profile/utils.py CHANGED
@@ -8,6 +8,94 @@ import httpx
8
  from typing import Dict, Optional
9
  from src.core.config import settings
10
  from urllib.parse import urlencode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
 
 
8
  from typing import Dict, Optional
9
  from src.core.config import settings
10
  from urllib.parse import urlencode
11
+ from datetime import date
12
+ from typing import Tuple, Optional, List
13
+ from sqlmodel import select
14
+ from sqlmodel.ext.asyncio.session import AsyncSession
15
+ from src.core.models import UserTeamsRole, Roles, Users, Teams # adjust import path if differs
16
+ from src.core.config import settings # for FCM key if needed
17
+ import httpx
18
+ import math
19
+
20
+ def calculate_days(from_date: date, to_date: date, include_weekends: bool = True) -> int:
21
+ """Calculate inclusive days. If you want to exclude weekends, add logic."""
22
+ delta = (to_date - from_date).days + 1
23
+ return max(0, delta)
24
+
25
+ async def find_mentor_and_lead(session: AsyncSession, user_id) -> Tuple[Optional[dict], Optional[dict]]:
26
+ """
27
+ Return (mentor_user, lead_user) as dicts or None.
28
+ Uses your existing UserTeamsRole and Roles tables to find role members in same team.
29
+ """
30
+ # 1) find user's team mapping
31
+ stmt = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
32
+ user_team = (await session.exec(stmt)).first()
33
+ if not user_team:
34
+ return None, None
35
+
36
+ # 2) find Mentor role id
37
+ mentor_role = (await session.exec(select(Roles).where(Roles.name == "Mentor"))).first()
38
+ lead_role = (await session.exec(select(Roles).where(Roles.name == "Team Lead"))).first()
39
+
40
+ mentor_user = None
41
+ lead_user = None
42
+
43
+ if mentor_role:
44
+ mentor_user = (await session.exec(
45
+ select(Users)
46
+ .join(UserTeamsRole)
47
+ .where(UserTeamsRole.team_id == user_team.team_id)
48
+ .where(UserTeamsRole.role_id == mentor_role.id)
49
+ )).first()
50
+
51
+ if lead_role:
52
+ lead_user = (await session.exec(
53
+ select(Users)
54
+ .join(UserTeamsRole)
55
+ .where(UserTeamsRole.team_id == user_team.team_id)
56
+ .where(UserTeamsRole.role_id == lead_role.id)
57
+ )).first()
58
+
59
+ return mentor_user, lead_user
60
+
61
+
62
+
63
+ async def get_tokens_for_user(session: AsyncSession, user_id) -> list[str]:
64
+ user = await session.get(Users, user_id)
65
+ if not user:
66
+ return []
67
+ return user.device_tokens or []
68
+
69
+ # Simple FCM send using legacy HTTP API (server key).
70
+ # In production prefer FCM HTTP v1 (OAuth) or firebase-admin SDK.
71
+ async def send_push_to_tokens(tokens: list[str], title: str, body: str, data: dict = None):
72
+ if not tokens:
73
+ return
74
+
75
+ server_key = getattr(settings, "FCM_SERVER_KEY", None)
76
+ if not server_key:
77
+ # no key configured: just log or skip
78
+ print("FCM_SERVER_KEY not configured, skipping push")
79
+ return
80
+
81
+ url = "https://fcm.googleapis.com/fcm/send"
82
+ headers = {
83
+ "Authorization": f"key={server_key}",
84
+ "Content-Type": "application/json",
85
+ }
86
+ payload = {
87
+ "registration_ids": tokens,
88
+ "notification": {"title": title, "body": body},
89
+ }
90
+ if data:
91
+ payload["data"] = data
92
+
93
+ async with httpx.AsyncClient(timeout=10.0) as client:
94
+ r = await client.post(url, json=payload, headers=headers)
95
+ # handle response in logs
96
+ if r.status_code != 200:
97
+ print("FCM send failed:", r.status_code, r.text)
98
+
99
 
100
 
101