Hp137 commited on
Commit
5e7c541
·
1 Parent(s): 853fb59

feat:added payslip table

Browse files
alembic/versions/fec3872d7eba_add_payslip_table.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add:payslip table
2
+
3
+ Revision ID: fec3872d7eba
4
+ Revises: f6a1d6fc82d0
5
+ Create Date: 2025-12-04 10:58:25.795533
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
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = 'fec3872d7eba'
17
+ down_revision: Union[str, Sequence[str], None] = 'f6a1d6fc82d0'
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.drop_constraint(op.f('comments_post_id_fkey'), 'comments', type_='foreignkey')
26
+ op.drop_constraint(op.f('comments_user_id_fkey'), 'comments', type_='foreignkey')
27
+ op.create_foreign_key(None, 'comments', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
28
+ op.create_foreign_key(None, 'comments', 'users', ['user_id'], ['id'], ondelete='CASCADE')
29
+ op.add_column('users', sa.Column('join_date', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
30
+ # ### end Alembic commands ###
31
+
32
+
33
+ def downgrade() -> None:
34
+ """Downgrade schema."""
35
+ # ### commands auto generated by Alembic - please adjust! ###
36
+ op.drop_column('users', 'join_date')
37
+ op.drop_constraint(None, 'comments', type_='foreignkey')
38
+ op.drop_constraint(None, 'comments', type_='foreignkey')
39
+ op.create_foreign_key(op.f('comments_user_id_fkey'), 'comments', 'users', ['user_id'], ['id'])
40
+ op.create_foreign_key(op.f('comments_post_id_fkey'), 'comments', 'posts', ['post_id'], ['id'])
41
+ # ### end Alembic commands ###
src/core/__init__.py CHANGED
@@ -4,4 +4,5 @@ from src.core import models as core_models
4
  from src.feed import models as feed_models
5
  from src.home import models as home_models
6
  from src.profile import models as profile_models
7
- from src.wellbeing import models as wellbeing_models
 
 
4
  from src.feed import models as feed_models
5
  from src.home import models as home_models
6
  from src.profile import models as profile_models
7
+ from src.wellbeing import models as wellbeing_models
8
+ from src.payslip import models as payslip_models
src/core/models.py CHANGED
@@ -30,6 +30,7 @@ class Users(SQLModel, table=True):
30
  dob: Optional[date] = None
31
  address: Optional[str] = None
32
  profile_picture: Optional[str] = None
 
33
  created_at: datetime = Field(default_factory=datetime.now)
34
  asset: List["Assets"] = Relationship(back_populates="user")
35
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
@@ -51,31 +52,36 @@ class UserTeamsRole(SQLModel, table=True):
51
  __tablename__ = "user_teams_role"
52
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
53
  user_id: uuid.UUID = Field(
54
- sa_column=Column(UUID(as_uuid=True),
 
55
  ForeignKey("users.id", ondelete="CASCADE"),
56
- nullable=False
57
  )
58
  )
59
  team_id: uuid.UUID = Field(
60
- sa_column=Column(UUID(as_uuid=True),
 
61
  ForeignKey("teams.id", ondelete="CASCADE"),
62
- nullable=False
63
  )
64
  )
65
  role_id: uuid.UUID = Field(
66
- sa_column=Column(UUID(as_uuid=True),
 
67
  ForeignKey("roles.id", ondelete="CASCADE"),
68
- nullable=False
69
  )
70
  )
71
 
 
72
  class Assets(SQLModel, table=True):
73
  __tablename__ = "assets"
74
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
75
  user_id: uuid.UUID = Field(
76
- sa_column=Column(UUID(as_uuid=True),
 
77
  ForeignKey("users.id", ondelete="CASCADE"),
78
- nullable=False
79
  )
80
  )
81
  name: str = Field(nullable=False)
@@ -93,9 +99,10 @@ class EmotionLogs(SQLModel, table=True):
93
  )
94
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
95
  user_id: uuid.UUID = Field(
96
- sa_column=Column(UUID(as_uuid=True),
 
97
  ForeignKey("users.id", ondelete="CASCADE"),
98
- nullable=False
99
  )
100
  )
101
  morning_emotion: Optional[int] = Field(default=None, ge=1, le=7)
 
30
  dob: Optional[date] = None
31
  address: Optional[str] = None
32
  profile_picture: Optional[str] = None
33
+ join_date: Optional[str] = None
34
  created_at: datetime = Field(default_factory=datetime.now)
35
  asset: List["Assets"] = Relationship(back_populates="user")
36
  water_logs: List["WaterLogs"] = Relationship(back_populates="user")
 
52
  __tablename__ = "user_teams_role"
53
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
54
  user_id: uuid.UUID = Field(
55
+ sa_column=Column(
56
+ UUID(as_uuid=True),
57
  ForeignKey("users.id", ondelete="CASCADE"),
58
+ nullable=False,
59
  )
60
  )
61
  team_id: uuid.UUID = Field(
62
+ sa_column=Column(
63
+ UUID(as_uuid=True),
64
  ForeignKey("teams.id", ondelete="CASCADE"),
65
+ nullable=False,
66
  )
67
  )
68
  role_id: uuid.UUID = Field(
69
+ sa_column=Column(
70
+ UUID(as_uuid=True),
71
  ForeignKey("roles.id", ondelete="CASCADE"),
72
+ nullable=False,
73
  )
74
  )
75
 
76
+
77
  class Assets(SQLModel, table=True):
78
  __tablename__ = "assets"
79
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
80
  user_id: uuid.UUID = Field(
81
+ sa_column=Column(
82
+ UUID(as_uuid=True),
83
  ForeignKey("users.id", ondelete="CASCADE"),
84
+ nullable=False,
85
  )
86
  )
87
  name: str = Field(nullable=False)
 
99
  )
100
  id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
101
  user_id: uuid.UUID = Field(
102
+ sa_column=Column(
103
+ UUID(as_uuid=True),
104
  ForeignKey("users.id", ondelete="CASCADE"),
105
+ nullable=False,
106
  )
107
  )
108
  morning_emotion: Optional[int] = Field(default=None, ge=1, le=7)
src/main.py CHANGED
@@ -7,6 +7,7 @@ from src.chatbot.router import router as chatbot_router
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
  from src.profile.router import router as profile
11
  from fastapi.staticfiles import StaticFiles
12
 
@@ -33,6 +34,8 @@ app.include_router(wellbeing)
33
 
34
  app.include_router(notifications_router)
35
 
 
 
36
 
37
  @app.get("/")
38
  def root():
 
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
+ from src.payslip.router import router as payslip_router
11
  from src.profile.router import router as profile
12
  from fastapi.staticfiles import StaticFiles
13
 
 
34
 
35
  app.include_router(notifications_router)
36
 
37
+ app.include_router(payslip_router)
38
+
39
 
40
  @app.get("/")
41
  def root():
src/payslip/__init__.py ADDED
File without changes
src/payslip/googleservice.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import requests
4
+ from typing import Tuple
5
+ from fastapi import HTTPException
6
+
7
+ from src.core.config import settings
8
+
9
+
10
+ def exchange_code_for_tokens(code: str):
11
+ """
12
+ Exchange Google 'code' for access_token + refresh_token
13
+ """
14
+ data = {
15
+ "code": code,
16
+ "client_id": settings.GOOGLE_CLIENT_ID,
17
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
18
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
19
+ "grant_type": "authorization_code",
20
+ }
21
+
22
+ res = requests.post(settings.TOKEN_URL, data=data)
23
+ if res.status_code != 200:
24
+ raise HTTPException(500, f"Google token exchange error: {res.text}")
25
+
26
+ return res.json()
27
+
28
+
29
+ def refresh_google_access_token(refresh_token: str) -> str:
30
+ """
31
+ Input → refresh_token
32
+ Output → new access_token
33
+ """
34
+ data = {
35
+ "refresh_token": refresh_token,
36
+ "client_id": settings.GOOGLE_CLIENT_ID,
37
+ "client_secret": settings.GOOGLE_CLIENT_SECRET,
38
+ "grant_type": "refresh_token",
39
+ }
40
+
41
+ res = requests.post(settings.TOKEN_URL, data=data)
42
+ if res.status_code != 200:
43
+ raise HTTPException(500, f"Failed to refresh access token: {res.text}")
44
+
45
+ return res.json()["access_token"]
46
+
47
+
48
+ def build_email(from_email: str, to_email: str, subject: str, body: str) -> str:
49
+ """
50
+ Gmail API expects Base64URL-encoded email.
51
+ """
52
+ message = (
53
+ f"From: {from_email}\r\n"
54
+ f"To: {to_email}\r\n"
55
+ f"Subject: {subject}\r\n"
56
+ "\r\n"
57
+ f"{body}"
58
+ )
59
+
60
+ message_bytes = message.encode("utf-8")
61
+ encoded = base64.urlsafe_b64encode(message_bytes).decode("utf-8")
62
+ return encoded
63
+
64
+
65
+ def send_gmail(access_token: str, raw_message: str) -> str:
66
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
67
+
68
+ headers = {
69
+ "Authorization": f"Bearer {access_token}",
70
+ "Content-Type": "application/json",
71
+ }
72
+
73
+ payload = {"raw": raw_message}
74
+
75
+ res = requests.post(url, headers=headers, data=json.dumps(payload))
76
+
77
+ if res.status_code not in (200, 202):
78
+ raise HTTPException(500, f"Gmail API error: {res.text}")
79
+
80
+ return res.json().get("id") # message_id
src/payslip/grouter.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from fastapi.responses import HTMLResponse
3
+ from urllib.parse import urlencode
4
+ import uuid
5
+
6
+ from src.core.config import settings
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from src.core.database import get_async_session
9
+ from src.core.models import Users
10
+ from src.payslip.models import PayslipRequest
11
+ from src.payslip.googleservice import exchange_code_for_tokens
12
+
13
+
14
+ router = APIRouter(prefix="/google", tags=["Google OAuth"])
15
+
16
+
17
+ @router.get("/connect-url")
18
+ def get_connect_url(user_id: uuid.UUID):
19
+ """
20
+ App calls this → backend returns Google OAuth URL
21
+ """
22
+ params = {
23
+ "client_id": settings.GOOGLE_CLIENT_ID,
24
+ "redirect_uri": settings.GOOGLE_REDIRECT_URI,
25
+ "response_type": "code",
26
+ "scope": settings.GMAIL_SEND_SCOPE,
27
+ "access_type": "offline",
28
+ "prompt": "consent",
29
+ "state": str(user_id),
30
+ }
31
+
32
+ url = f"{settings.AUTH_BASE}?{urlencode(params)}"
33
+ return {"auth_url": url}
34
+
35
+
36
+ @router.get("/callback", response_class=HTMLResponse)
37
+ def google_callback(
38
+ code: str,
39
+ state: str,
40
+ session: AsyncSession = Depends(get_async_session),
41
+ ):
42
+ """
43
+ Google redirects here after user logs in.
44
+ We store the refresh token in payslip table.
45
+ """
46
+ user_id = uuid.UUID(state)
47
+ user = session.get(Users, user_id)
48
+
49
+ if not user:
50
+ raise HTTPException(404, "User not found")
51
+
52
+ token_data = exchange_code_for_tokens(code)
53
+
54
+ refresh_token = token_data.get("refresh_token")
55
+ if not refresh_token:
56
+ raise HTTPException(400, "No refresh token received. Try again.")
57
+
58
+ # Save refresh token using a dummy payslip entry
59
+ entry = PayslipRequest(
60
+ user_id=user_id,
61
+ refresh_token=refresh_token,
62
+ status="Pending",
63
+ )
64
+ session.add(entry)
65
+ session.commit()
66
+
67
+ return """
68
+ <h2>Gmail Connected Successfully!</h2>
69
+ <p>You can close this window.</p>
70
+ """
src/payslip/models.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from sqlalchemy.dialects.postgresql import UUID
7
+ from sqlalchemy import Column
8
+ from sqlmodel import SQLModel, Field, ForeignKey
9
+
10
+
11
+ class PayslipStatus(str, Enum):
12
+ PENDING = "Pending"
13
+ SENT = "Sent"
14
+ FAILED = "Failed"
15
+
16
+
17
+ class PayslipRequest(SQLModel, table=True):
18
+ __tablename__ = "payslip_requests"
19
+
20
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
21
+
22
+ user_id: uuid.UUID = Field(
23
+ sa_column=Column(
24
+ UUID(as_uuid=True),
25
+ ForeignKey("users.id", ondelete="CASCADE"),
26
+ nullable=False,
27
+ )
28
+ )
29
+
30
+ requested_at: datetime = Field(default_factory=datetime.now)
31
+
32
+ status: PayslipStatus = Field(default=PayslipStatus.PENDING)
33
+
34
+ refresh_token: Optional[str] = None
35
+
36
+ error_message: Optional[str] = None
src/payslip/router.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from src.payslip.schemas import PayslipRequestSchema
3
+ from src.payslip.service import process_payslip_request
4
+ from src.auth.utils import get_current_user
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from src.core.database import get_async_session
7
+ from src.core.models import Users
8
+
9
+ router = APIRouter(prefix="/payslips", tags=["Payslips"])
10
+
11
+
12
+ @router.post("/request")
13
+ def request_payslip(
14
+ payload: PayslipRequestSchema,
15
+ session: AsyncSession = Depends(get_async_session),
16
+ user: Users = Depends(get_current_user),
17
+ ):
18
+ entry = process_payslip_request(session, user, payload)
19
+ return {
20
+ "status": entry.status,
21
+ "requested_at": entry.requested_at,
22
+ }
src/payslip/schemas.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, Literal
3
+
4
+
5
+ class PayslipRequestSchema(BaseModel):
6
+ mode: Literal["3_months", "6_months", "manual"]
7
+ start_month: Optional[str] = Field(default=None, description="YYYY-MM")
8
+ end_month: Optional[str] = Field(default=None, description="YYYY-MM")
src/payslip/service.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import Session, select
2
+ from fastapi import HTTPException
3
+ from src.payslip.models import PayslipRequest, PayslipStatus
4
+ from src.core.models import Users, Roles, UserTeamsRole
5
+
6
+ from src.payslip.utils import calculate_period, validate_join_date
7
+ from src.payslip.googleservice import (
8
+ refresh_google_access_token,
9
+ build_email,
10
+ send_gmail,
11
+ )
12
+
13
+
14
+ def get_latest_refresh_token(session: Session, user_id):
15
+ stmt = (
16
+ select(PayslipRequest)
17
+ .where(
18
+ PayslipRequest.user_id == user_id,
19
+ PayslipRequest.refresh_token.is_not(None),
20
+ )
21
+ .order_by(PayslipRequest.requested_at.desc())
22
+ )
23
+ entry = session.exec(stmt).first()
24
+ return entry.refresh_token if entry else None
25
+
26
+
27
+ def get_hr_email(session: Session) -> str:
28
+ role = session.exec(select(Roles).where(Roles.name == "HR Manager")).first()
29
+ if not role:
30
+ raise HTTPException(500, "HR Manager role missing")
31
+
32
+ mapping = session.exec(
33
+ select(UserTeamsRole).where(UserTeamsRole.role_id == role.id)
34
+ ).first()
35
+
36
+ if not mapping:
37
+ raise HTTPException(500, "No HR assigned")
38
+
39
+ hr_user = session.get(Users, mapping.user_id)
40
+ return hr_user.email_id
41
+
42
+
43
+ def process_payslip_request(session: Session, user: Users, payload):
44
+ # 1. Compute period
45
+ period_start, period_end = calculate_period(
46
+ payload.mode, payload.start_month, payload.end_month
47
+ )
48
+
49
+ # 2. Validate join date
50
+ validate_join_date(user.join_date, period_start)
51
+
52
+ # 3. Find refresh_token
53
+ refresh_token = get_latest_refresh_token(session, user.id)
54
+
55
+ if not refresh_token:
56
+ raise HTTPException(
57
+ 400, "Please connect your Gmail before requesting a payslip."
58
+ )
59
+
60
+ # 4. Get access token
61
+ access_token = refresh_google_access_token(refresh_token)
62
+
63
+ # 5. Find HR email
64
+ hr_email = get_hr_email(session)
65
+
66
+ # 6. Build message
67
+ subject = "Payslip Request"
68
+ body = (
69
+ f"Payslip request from {user.email_id}\n"
70
+ f"Period: {period_start} → {period_end}"
71
+ )
72
+ raw = build_email(user.email_id, hr_email, subject, body)
73
+
74
+ # 7. Send email
75
+ message_id = send_gmail(access_token, raw)
76
+
77
+ # 8. Create new DB record
78
+ entry = PayslipRequest(
79
+ user_id=user.id,
80
+ status=PayslipStatus.SENT,
81
+ refresh_token=refresh_token,
82
+ error_message=None,
83
+ )
84
+
85
+ session.add(entry)
86
+ session.commit()
87
+ session.refresh(entry)
88
+
89
+ return entry
src/payslip/utils.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime
2
+ from fastapi import HTTPException
3
+
4
+
5
+ def parse_month(month_str: str) -> date:
6
+ """Convert YYYY-MM to a date object representing first day of the month."""
7
+ try:
8
+ d = datetime.strptime(month_str, "%Y-%m")
9
+ return date(d.year, d.month, 1)
10
+ except:
11
+ raise HTTPException(status_code=400, detail="Invalid month format. Use YYYY-MM")
12
+
13
+
14
+ def calculate_period(mode: str, start: str | None, end: str | None):
15
+ today = date.today()
16
+ current_month = date(today.year, today.month, 1)
17
+
18
+ if mode == "3_months":
19
+ months = 3
20
+ elif mode == "6_months":
21
+ months = 6
22
+ else: # manual mode
23
+ if not start or not end:
24
+ raise HTTPException(400, "start_month and end_month required")
25
+ start_date = parse_month(start)
26
+ end_date = parse_month(end)
27
+
28
+ if end_date > current_month:
29
+ raise HTTPException(400, "Cannot request future payslips")
30
+ if start_date > end_date:
31
+ raise HTTPException(400, "start_month cannot be after end_month")
32
+
33
+ return start_date, end_date
34
+
35
+ # Auto period
36
+ end_date = current_month
37
+ year = end_date.year
38
+ mon = end_date.month - (months - 1)
39
+
40
+ while mon <= 0:
41
+ mon += 12
42
+ year -= 1
43
+
44
+ start_date = date(year, mon, 1)
45
+ return start_date, end_date
46
+
47
+
48
+ def validate_join_date(join_date_str: str, period_start: date):
49
+ """User cannot request payslips earlier than join date"""
50
+ try:
51
+ join = datetime.strptime(join_date_str, "%Y-%m-%d").date()
52
+ join_month = join.replace(day=1)
53
+ except:
54
+ raise HTTPException(500, "Invalid join_date format in DB")
55
+
56
+ if period_start < join_month:
57
+ raise HTTPException(400, "You cannot request payslips before your joining date")