Spaces:
Sleeping
Sleeping
feat:added payslip table
Browse files- alembic/versions/fec3872d7eba_add_payslip_table.py +41 -0
- src/core/__init__.py +2 -1
- src/core/models.py +17 -10
- src/main.py +3 -0
- src/payslip/__init__.py +0 -0
- src/payslip/googleservice.py +80 -0
- src/payslip/grouter.py +70 -0
- src/payslip/models.py +36 -0
- src/payslip/router.py +22 -0
- src/payslip/schemas.py +8 -0
- src/payslip/service.py +89 -0
- src/payslip/utils.py +57 -0
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(
|
|
|
|
| 55 |
ForeignKey("users.id", ondelete="CASCADE"),
|
| 56 |
-
nullable=False
|
| 57 |
)
|
| 58 |
)
|
| 59 |
team_id: uuid.UUID = Field(
|
| 60 |
-
sa_column=Column(
|
|
|
|
| 61 |
ForeignKey("teams.id", ondelete="CASCADE"),
|
| 62 |
-
nullable=False
|
| 63 |
)
|
| 64 |
)
|
| 65 |
role_id: uuid.UUID = Field(
|
| 66 |
-
sa_column=Column(
|
|
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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")
|