Shri Jayaram commited on
Commit
8d12150
·
2 Parent(s): 85f310a b78a7c7

Merge pull request #18 from yuvabe-ai-labs/feat/profile

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
 
@@ -33,6 +34,18 @@ class Settings(BaseSettings):
33
  FERNET_KEY: str
34
  VERIFICATION_BASE_URL: str
35
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @computed_field
37
  @property
38
  def DATABASE_URL(self) -> PostgresDsn:
@@ -45,7 +58,9 @@ class Settings(BaseSettings):
45
  """Async DB URL"""
46
  return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}/{self.POSTGRES_DB}"
47
 
48
- model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
 
 
49
 
50
 
51
  settings = Settings()
 
1
+ from typing import Optional
2
  from pydantic import PostgresDsn, computed_field
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
4
 
 
34
  FERNET_KEY: str
35
  VERIFICATION_BASE_URL: str
36
 
37
+ GOOGLE_CLIENT_ID: str
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"
48
+
49
  @computed_field
50
  @property
51
  def DATABASE_URL(self) -> PostgresDsn:
 
58
  """Async DB URL"""
59
  return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}/{self.POSTGRES_DB}"
60
 
61
+ model_config = SettingsConfigDict(
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
@@ -1,16 +1,19 @@
 
1
  from fastapi import FastAPI
2
 
 
3
  from src.auth.router import router as auth_router
4
  from src.chatbot.router import router as chatbot
5
  from src.core.database import init_db
6
  from src.home.router import router as home_router
 
7
  from src.profile.router import router as profile
8
 
9
  app = FastAPI(title="Yuvabe App API")
10
 
11
  app.include_router(home_router, prefix="/home", tags=["Home"])
12
 
13
- # init_db()
14
 
15
  app.include_router(auth_router)
16
 
 
1
+ from src.profile.router import router as profile
2
  from fastapi import FastAPI
3
 
4
+
5
  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
 
12
  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
 
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
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  from fastapi.routing import APIRouter
2
  from src.core.database import get_async_session
3
  from src.auth.utils import get_current_user
@@ -6,21 +13,163 @@ from sqlalchemy.ext.asyncio.session import AsyncSession
6
  from fastapi.params import Depends
7
  from .schemas import UpdateProfileRequest
8
  from src.profile.service import update_user_profile
9
- from fastapi import APIRouter, Depends
10
- from sqlmodel.ext.asyncio.session import AsyncSession
11
- from src.core.database import get_async_session
12
- from src.auth.utils import get_current_user
13
- from fastapi import APIRouter, Depends, HTTPException
14
  from sqlmodel import select
15
- from sqlmodel.ext.asyncio.session import AsyncSession
16
- from src.auth.utils import get_current_user
17
  from src.core.models import Users, Teams, Roles, UserTeamsRole
18
- from fastapi import BackgroundTasks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  router = APIRouter(prefix="/profile", tags=["Profile"])
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  @router.get("/", response_model=BaseResponse)
25
  async def get_assets(
26
  user_id: str = Depends(get_current_user),
@@ -53,7 +202,6 @@ async def update_profile(
53
  return {"code": 200, "data": result}
54
 
55
 
56
-
57
  @router.get("/contacts", response_model=BaseResponse)
58
  async def get_leave_contacts(
59
  current_user=Depends(get_current_user),
@@ -132,4 +280,3 @@ async def send_leave_email(
132
  background.add_task(send_email, to_email, subject, body, cc, from_email)
133
 
134
  return BaseResponse(code=200, message="Leave request sent", data=None)
135
-
 
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
 
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),
 
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),
 
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)
 
src/profile/schemas.py CHANGED
@@ -1,7 +1,47 @@
1
- from pydantic import BaseModel, EmailStr
2
- from typing import Optional
3
  import uuid
4
  from enum import Enum
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  class AssetStatus(str, Enum):
7
  ACTIVE = "Active"
@@ -9,16 +49,19 @@ class AssetStatus(str, Enum):
9
  ON_REQUEST = "On Request"
10
  IN_SERVICE = "In Service"
11
 
 
12
  class AssetCreateRequest(BaseModel):
13
  name: str
14
  type: str
15
  status: Optional[AssetStatus] = AssetStatus.UNAVAILABLE
16
 
 
17
  class AssetUpdateRequest(BaseModel):
18
  name: Optional[str] = None
19
  type: Optional[str] = None
20
  status: Optional[AssetStatus] = None
21
 
 
22
  class AssetResponse(BaseModel):
23
  id: uuid.UUID
24
  user_id: uuid.UUID
@@ -26,6 +69,7 @@ class AssetResponse(BaseModel):
26
  type: str
27
  status: AssetStatus
28
 
 
29
  class BaseResponse(BaseModel):
30
  code: int
31
  data: dict
@@ -39,3 +83,11 @@ class UpdateProfileRequest(BaseModel):
39
 
40
  current_password: Optional[str] = None
41
  new_password: Optional[str] = None
 
 
 
 
 
 
 
 
 
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"
 
49
  ON_REQUEST = "On Request"
50
  IN_SERVICE = "In Service"
51
 
52
+
53
  class AssetCreateRequest(BaseModel):
54
  name: str
55
  type: str
56
  status: Optional[AssetStatus] = AssetStatus.UNAVAILABLE
57
 
58
+
59
  class AssetUpdateRequest(BaseModel):
60
  name: Optional[str] = None
61
  type: Optional[str] = None
62
  status: Optional[AssetStatus] = None
63
 
64
+
65
  class AssetResponse(BaseModel):
66
  id: uuid.UUID
67
  user_id: uuid.UUID
 
69
  type: str
70
  status: AssetStatus
71
 
72
+
73
  class BaseResponse(BaseModel):
74
  code: int
75
  data: dict
 
83
 
84
  current_password: Optional[str] = None
85
  new_password: Optional[str] = None
86
+
87
+
88
+ class SendMailRequest(BaseModel):
89
+ user_id: str
90
+ to: EmailStr
91
+ subject: str
92
+ body: str
93
+ from_name: Optional[str] = None
src/profile/service.py CHANGED
@@ -1,5 +1,6 @@
1
- from src.core.models import Assets
2
- from ast import List
 
3
  from datetime import datetime
4
  import uuid
5
  from fastapi import HTTPException
@@ -9,10 +10,316 @@ import uuid
9
  from typing import List
10
  from sqlmodel import select
11
  from sqlmodel.ext.asyncio.session import AsyncSession
12
- from src.core.models import Assets
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  async def update_user_profile(session, user_id: str, data):
18
  user = await session.get(Users, uuid.UUID(user_id))
@@ -71,8 +378,7 @@ async def update_user_profile(session, user_id: str, data):
71
  },
72
  }
73
 
 
74
  async def list_user_assets(session: AsyncSession, user_id: str) -> List[Assets]:
75
- q = await session.exec(
76
- select(Assets).where(Assets.user_id == uuid.UUID(user_id))
77
- )
78
- return q.all()
 
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}}
284
+
285
+
286
+ async def send_email_service(req: SendMailRequest):
287
+ record = USER_TOKEN_STORE.get(req.user_id)
288
+ if not record:
289
+ raise HTTPException(404, "User not logged in with Google OAuth")
290
+
291
+ access_token = record["access_token"]
292
+ refresh_token = record.get("refresh_token")
293
+
294
+ if not access_token and refresh_token:
295
+ new_tokens = await refresh_access_token(refresh_token)
296
+ access_token = new_tokens["access_token"]
297
+ record["access_token"] = access_token
298
+
299
+ if not access_token:
300
+ raise HTTPException(400, "Re-auth required")
301
+
302
+ raw = build_raw_message(
303
+ to_email=req.to,
304
+ subject=req.subject,
305
+ body=req.body,
306
+ from_name=req.from_name,
307
+ from_email=record["email"],
308
+ )
309
+
310
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
311
+ payload = {"raw": raw}
312
+
313
+ async with httpx.AsyncClient() as client:
314
+ r = await client.post(
315
+ url, json=payload, headers={"Authorization": f"Bearer {access_token}"}
316
+ )
317
+
318
+ if r.status_code >= 400:
319
+ raise HTTPException(500, f"Gmail error: {r.text}")
320
+
321
+ return r.json()
322
+
323
 
324
  async def update_user_profile(session, user_id: str, data):
325
  user = await session.get(Users, uuid.UUID(user_id))
 
378
  },
379
  }
380
 
381
+
382
  async def list_user_assets(session: AsyncSession, user_id: str) -> List[Assets]:
383
+ q = await session.exec(select(Assets).where(Assets.user_id == uuid.UUID(user_id)))
384
+ return q.all()
 
 
src/profile/utils.py CHANGED
@@ -3,6 +3,100 @@ import smtplib
3
  from email.message import EmailMessage
4
  from src.core.config import settings
5
  from typing import List
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
  SMTP_HOST = settings.EMAIL_SERVER
@@ -11,6 +105,69 @@ SMTP_USER = settings.EMAIL_USERNAME
11
  SMTP_PASS = settings.EMAIL_PASSWORD
12
  FROM_DEFAULT = settings.EMAIL_USERNAME
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  def send_email(
16
  to_email: str, subject: str, body: str, cc: list[str] = None, from_email: str = None
 
3
  from email.message import EmailMessage
4
  from src.core.config import settings
5
  from typing import List
6
+ import base64
7
+ import httpx
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
 
102
  SMTP_HOST = settings.EMAIL_SERVER
 
105
  SMTP_PASS = settings.EMAIL_PASSWORD
106
  FROM_DEFAULT = settings.EMAIL_USERNAME
107
 
108
+ # src/utils/gmail_utils.py
109
+
110
+
111
+ def build_auth_url(state=None):
112
+ params = {
113
+ "client_id": settings.GOOGLE_CLIENT_ID,
114
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
115
+ "response_type": "code",
116
+ "scope": settings.GMAIL_SEND_SCOPE + " openid email profile",
117
+ "access_type": "offline",
118
+ "prompt": "consent",
119
+ }
120
+
121
+ if state:
122
+ params["state"] = state
123
+
124
+ query = urlencode(params)
125
+ return f"{settings.AUTH_BASE}?{query}"
126
+
127
+
128
+ async def exchange_code_for_tokens(code: str) -> Dict:
129
+ data = {
130
+ "code": code,
131
+ "client_id": settings.GOOGLE_CLIENT_ID,
132
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
133
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
134
+ "grant_type": "authorization_code",
135
+ }
136
+
137
+ async with httpx.AsyncClient() as client:
138
+ r = await client.post(settings.TOKEN_URL, data=data)
139
+ r.raise_for_status()
140
+ return r.json()
141
+
142
+
143
+ async def refresh_access_token(refresh_token: str) -> Dict:
144
+ data = {
145
+ "client_id": settings.GOOGLE_CLIENT_ID,
146
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
147
+ "refresh_token": refresh_token,
148
+ "grant_type": "refresh_token",
149
+ }
150
+
151
+ async with httpx.AsyncClient() as client:
152
+ r = await client.post(settings.TOKEN_URL, data=data)
153
+ r.raise_for_status()
154
+ return r.json()
155
+
156
+
157
+ def build_raw_message(
158
+ to_email: str, subject: str, body: str, from_name: Optional[str], from_email: str
159
+ ) -> str:
160
+
161
+ msg = EmailMessage()
162
+ sender = f"{from_name} <{from_email}>" if from_name else from_email
163
+ msg["From"] = sender
164
+ msg["To"] = to_email
165
+ msg["Subject"] = subject
166
+ msg.set_content(body)
167
+
168
+ raw_bytes = msg.as_bytes()
169
+ return base64.urlsafe_b64encode(raw_bytes).decode()
170
+
171
 
172
  def send_email(
173
  to_email: str, subject: str, body: str, cc: list[str] = None, from_email: str = None