Spaces:
Sleeping
Sleeping
Fixed
#3
by Hp137 - opened
- alembic/versions/6afe00260026_add_nick_name_to_users_table.py +0 -35
- alembic/versions/f15af71aacb9_add_request_type_and_target_dates_to_.py +0 -52
- src/auth/router.py +9 -17
- src/core/config.py +1 -2
- src/core/database.py +0 -3
- src/core/models.py +2 -11
- src/core/utils.py +2 -2
- src/foodcount/service.py +13 -58
- src/main.py +0 -10
- src/payslip/googleservice.py +1 -8
- src/payslip/models.py +2 -13
- src/payslip/router.py +15 -79
- src/payslip/service.py +5 -12
- src/profile/notify.py +1 -10
- src/profile/router.py +8 -14
- src/profile/schemas.py +1 -1
- src/profile/service.py +6 -27
alembic/versions/6afe00260026_add_nick_name_to_users_table.py
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
"""add nick_name to users table
|
| 2 |
-
|
| 3 |
-
Revision ID: 6afe00260026
|
| 4 |
-
Revises: 0875ad9f64a9
|
| 5 |
-
Create Date: 2026-02-03 12:08:55.758775
|
| 6 |
-
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from typing import Sequence, Union
|
| 10 |
-
|
| 11 |
-
from alembic import op
|
| 12 |
-
import sqlalchemy as sa
|
| 13 |
-
import sqlmodel.sql.sqltypes
|
| 14 |
-
from sqlalchemy.dialects import postgresql
|
| 15 |
-
|
| 16 |
-
# revision identifiers, used by Alembic.
|
| 17 |
-
revision: str = "6afe00260026"
|
| 18 |
-
down_revision: Union[str, Sequence[str], None] = "0875ad9f64a9"
|
| 19 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 20 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def upgrade() -> None:
|
| 24 |
-
"""Upgrade schema."""
|
| 25 |
-
# Only keep the column addition
|
| 26 |
-
op.add_column(
|
| 27 |
-
"users",
|
| 28 |
-
sa.Column("nick_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def downgrade() -> None:
|
| 33 |
-
"""Downgrade schema."""
|
| 34 |
-
# Only keep the column removal
|
| 35 |
-
op.drop_column("users", "nick_name")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
alembic/versions/f15af71aacb9_add_request_type_and_target_dates_to_.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
"""add request_type and target_dates to payslip_requests
|
| 2 |
-
|
| 3 |
-
Revision ID: f15af71aacb9
|
| 4 |
-
Revises: 6afe00260026
|
| 5 |
-
Create Date: 2026-02-09 14:24:49.103516
|
| 6 |
-
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from typing import Sequence, Union
|
| 10 |
-
|
| 11 |
-
from alembic import op
|
| 12 |
-
import sqlalchemy as sa
|
| 13 |
-
import sqlmodel.sql.sqltypes
|
| 14 |
-
from sqlalchemy.dialects import postgresql
|
| 15 |
-
|
| 16 |
-
# revision identifiers, used by Alembic.
|
| 17 |
-
revision: str = "f15af71aacb9"
|
| 18 |
-
down_revision: Union[str, Sequence[str], None] = "6afe00260026"
|
| 19 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 20 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def upgrade() -> None:
|
| 24 |
-
"""Upgrade schema."""
|
| 25 |
-
# 1. Add request_type
|
| 26 |
-
# We use server_default to fill existing rows with 'payslip' so the migration doesn't crash
|
| 27 |
-
op.add_column(
|
| 28 |
-
"payslip_requests",
|
| 29 |
-
sa.Column(
|
| 30 |
-
"request_type",
|
| 31 |
-
sqlmodel.sql.sqltypes.AutoString(),
|
| 32 |
-
nullable=False,
|
| 33 |
-
server_default=sa.text("'payslip'"),
|
| 34 |
-
),
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
# 2. Optional: Remove the default constraint if you don't want it permanently (keeps schema clean)
|
| 38 |
-
op.alter_column("payslip_requests", "request_type", server_default=None)
|
| 39 |
-
|
| 40 |
-
# 3. Add target_dates (It is nullable, so no default needed)
|
| 41 |
-
op.add_column(
|
| 42 |
-
"payslip_requests",
|
| 43 |
-
sa.Column(
|
| 44 |
-
"target_dates", postgresql.JSON(astext_type=sa.Text()), nullable=True
|
| 45 |
-
),
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def downgrade() -> None:
|
| 50 |
-
"""Downgrade schema."""
|
| 51 |
-
op.drop_column("payslip_requests", "target_dates")
|
| 52 |
-
op.drop_column("payslip_requests", "request_type")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/auth/router.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
from src.core.models import Teams
|
| 2 |
import uuid
|
| 3 |
from src.core.database import get_async_session
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException, status
|
|
@@ -114,26 +113,21 @@ async def get_home(
|
|
| 114 |
user_id: str = Depends(get_current_user),
|
| 115 |
session: AsyncSession = Depends(get_async_session),
|
| 116 |
):
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
if not user:
|
| 120 |
raise HTTPException(status_code=404, detail="User not found")
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
select(Roles.name, Teams.name)
|
| 125 |
.join(UserTeamsRole, UserTeamsRole.role_id == Roles.id)
|
| 126 |
-
.
|
| 127 |
-
.where(UserTeamsRole.user_id == u_id)
|
| 128 |
)
|
|
|
|
| 129 |
|
| 130 |
-
#
|
| 131 |
-
exec_result = await session.exec(statement)
|
| 132 |
-
row = exec_result.first()
|
| 133 |
-
|
| 134 |
-
# Unpack values or provide defaults
|
| 135 |
-
user_role, team_name = row if row else ("Member", "No Team")
|
| 136 |
-
|
| 137 |
return {
|
| 138 |
"code": 200,
|
| 139 |
"data": {
|
|
@@ -146,8 +140,6 @@ async def get_home(
|
|
| 146 |
"dob": user.dob.isoformat() if user.dob else None,
|
| 147 |
"profile_picture": user.profile_picture,
|
| 148 |
"role": user_role.lower(),
|
| 149 |
-
"team_name": team_name,
|
| 150 |
-
"nick_name": user.nick_name,
|
| 151 |
},
|
| 152 |
"home_data": {
|
| 153 |
"announcements": ["Welcome!", "New protocol released"],
|
|
|
|
|
|
|
| 1 |
import uuid
|
| 2 |
from src.core.database import get_async_session
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
| 113 |
user_id: str = Depends(get_current_user),
|
| 114 |
session: AsyncSession = Depends(get_async_session),
|
| 115 |
):
|
| 116 |
+
"""
|
| 117 |
+
Protected home endpoint. Requires a valid access token (Bearer).
|
| 118 |
+
"""
|
| 119 |
+
user = await session.get(Users, uuid.UUID(user_id))
|
| 120 |
if not user:
|
| 121 |
raise HTTPException(status_code=404, detail="User not found")
|
| 122 |
|
| 123 |
+
role_join = await session.exec(
|
| 124 |
+
select(Roles.name)
|
|
|
|
| 125 |
.join(UserTeamsRole, UserTeamsRole.role_id == Roles.id)
|
| 126 |
+
.where(UserTeamsRole.user_id == uuid.UUID(user_id))
|
|
|
|
| 127 |
)
|
| 128 |
+
user_role = role_join.first() or "Member"
|
| 129 |
|
| 130 |
+
# Example payload — replace with your real app data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return {
|
| 132 |
"code": 200,
|
| 133 |
"data": {
|
|
|
|
| 140 |
"dob": user.dob.isoformat() if user.dob else None,
|
| 141 |
"profile_picture": user.profile_picture,
|
| 142 |
"role": user_role.lower(),
|
|
|
|
|
|
|
| 143 |
},
|
| 144 |
"home_data": {
|
| 145 |
"announcements": ["Welcome!", "New protocol released"],
|
src/core/config.py
CHANGED
|
@@ -38,8 +38,6 @@ class Settings(BaseSettings):
|
|
| 38 |
GOOGLE_CLIENT_SECRET: str
|
| 39 |
GOOGLE_REDIRECT_URI: str
|
| 40 |
|
| 41 |
-
WEB_FRONTEND_URL: str
|
| 42 |
-
|
| 43 |
FCM_SERVER_KEY: Optional[str] = None
|
| 44 |
SICK_LEAVE_LIMIT: int = 10
|
| 45 |
CASUAL_LEAVE_LIMIT: int = 10
|
|
@@ -60,6 +58,7 @@ class Settings(BaseSettings):
|
|
| 60 |
FIREBASE_CLIENT_X509_CERT_URL: str
|
| 61 |
FIREBASE_UNIVERSE_DOMAIN: str
|
| 62 |
|
|
|
|
| 63 |
@computed_field
|
| 64 |
@property
|
| 65 |
def DATABASE_URL(self) -> PostgresDsn:
|
|
|
|
| 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
|
|
|
|
| 58 |
FIREBASE_CLIENT_X509_CERT_URL: str
|
| 59 |
FIREBASE_UNIVERSE_DOMAIN: str
|
| 60 |
|
| 61 |
+
|
| 62 |
@computed_field
|
| 63 |
@property
|
| 64 |
def DATABASE_URL(self) -> PostgresDsn:
|
src/core/database.py
CHANGED
|
@@ -12,9 +12,6 @@ async_engine = create_async_engine(
|
|
| 12 |
pool_size=20,
|
| 13 |
max_overflow=40,
|
| 14 |
pool_timeout=30,
|
| 15 |
-
pool_recycle=300,
|
| 16 |
-
pool_pre_ping=True,
|
| 17 |
-
pool_use_lifo=True,
|
| 18 |
connect_args={"ssl": True},
|
| 19 |
)
|
| 20 |
|
|
|
|
| 12 |
pool_size=20,
|
| 13 |
max_overflow=40,
|
| 14 |
pool_timeout=30,
|
|
|
|
|
|
|
|
|
|
| 15 |
connect_args={"ssl": True},
|
| 16 |
)
|
| 17 |
|
src/core/models.py
CHANGED
|
@@ -33,21 +33,18 @@ class LunchLocation(str, Enum):
|
|
| 33 |
SARACON_CAMPUS = "Saracon Campus"
|
| 34 |
SOLAR_KITCHEN = "Solar Kitchen"
|
| 35 |
|
| 36 |
-
|
| 37 |
class AppVersion(SQLModel, table=True):
|
| 38 |
__tablename__ = "app_version"
|
| 39 |
version: str = Field(primary_key=True)
|
| 40 |
apk_download_link: str = Field(nullable=False)
|
| 41 |
ios_download_link: str = Field(nullable=False)
|
| 42 |
|
| 43 |
-
|
| 44 |
class Users(SQLModel, table=True):
|
| 45 |
__tablename__ = "users"
|
| 46 |
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 47 |
email_id: str = Field(unique=True, nullable=False)
|
| 48 |
password: str = Field(nullable=False)
|
| 49 |
user_name: str = Field(nullable=False)
|
| 50 |
-
nick_name: Optional[str] = Field(default=None, nullable=True)
|
| 51 |
is_verified: bool = Field(
|
| 52 |
default=False, sa_column_kwargs={"server_default": "false"}
|
| 53 |
)
|
|
@@ -59,9 +56,7 @@ class Users(SQLModel, table=True):
|
|
| 59 |
asset: List["Assets"] = Relationship(back_populates="user")
|
| 60 |
water_logs: List["WaterLogs"] = Relationship(back_populates="user")
|
| 61 |
journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
|
| 62 |
-
lunch_preference: LunchLocation = Field(
|
| 63 |
-
default=None, sa_column_kwargs={"server_default": "SOLAR_KITCHEN"}
|
| 64 |
-
)
|
| 65 |
|
| 66 |
|
| 67 |
class Teams(SQLModel, table=True):
|
|
@@ -72,11 +67,7 @@ class Teams(SQLModel, table=True):
|
|
| 72 |
|
| 73 |
class Roles(SQLModel, table=True):
|
| 74 |
__tablename__ = "roles"
|
| 75 |
-
id: uuid.UUID = Field(
|
| 76 |
-
default_factory=uuid.uuid4,
|
| 77 |
-
primary_key=True,
|
| 78 |
-
sa_column_kwargs={"server_default": text("gen_random_uuid()")},
|
| 79 |
-
)
|
| 80 |
name: str = Field(unique=True, nullable=False)
|
| 81 |
|
| 82 |
|
|
|
|
| 33 |
SARACON_CAMPUS = "Saracon Campus"
|
| 34 |
SOLAR_KITCHEN = "Solar Kitchen"
|
| 35 |
|
|
|
|
| 36 |
class AppVersion(SQLModel, table=True):
|
| 37 |
__tablename__ = "app_version"
|
| 38 |
version: str = Field(primary_key=True)
|
| 39 |
apk_download_link: str = Field(nullable=False)
|
| 40 |
ios_download_link: str = Field(nullable=False)
|
| 41 |
|
|
|
|
| 42 |
class Users(SQLModel, table=True):
|
| 43 |
__tablename__ = "users"
|
| 44 |
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 45 |
email_id: str = Field(unique=True, nullable=False)
|
| 46 |
password: str = Field(nullable=False)
|
| 47 |
user_name: str = Field(nullable=False)
|
|
|
|
| 48 |
is_verified: bool = Field(
|
| 49 |
default=False, sa_column_kwargs={"server_default": "false"}
|
| 50 |
)
|
|
|
|
| 56 |
asset: List["Assets"] = Relationship(back_populates="user")
|
| 57 |
water_logs: List["WaterLogs"] = Relationship(back_populates="user")
|
| 58 |
journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
|
| 59 |
+
lunch_preference: LunchLocation = Field(default= None,sa_column_kwargs={"server_default": "SOLAR_KITCHEN"})
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
class Teams(SQLModel, table=True):
|
|
|
|
| 67 |
|
| 68 |
class Roles(SQLModel, table=True):
|
| 69 |
__tablename__ = "roles"
|
| 70 |
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, sa_column_kwargs={"server_default": text("gen_random_uuid()")})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
name: str = Field(unique=True, nullable=False)
|
| 72 |
|
| 73 |
|
src/core/utils.py
CHANGED
|
@@ -22,8 +22,8 @@ async def send_mail_as_user(
|
|
| 22 |
):
|
| 23 |
q = (
|
| 24 |
select(PayslipRequest)
|
| 25 |
-
.where(PayslipRequest.user_id == user_id
|
| 26 |
-
.order_by(PayslipRequest.requested_at.desc())
|
| 27 |
)
|
| 28 |
entry = (await session.execute(q)).scalar_one_or_none()
|
| 29 |
|
|
|
|
| 22 |
):
|
| 23 |
q = (
|
| 24 |
select(PayslipRequest)
|
| 25 |
+
.where(PayslipRequest.user_id == user_id)
|
| 26 |
+
.order_by(PayslipRequest.requested_at.desc())
|
| 27 |
)
|
| 28 |
entry = (await session.execute(q)).scalar_one_or_none()
|
| 29 |
|
src/foodcount/service.py
CHANGED
|
@@ -7,15 +7,12 @@ from src.core.models import Users, Roles, UserTeamsRole
|
|
| 7 |
from src.foodcount.schemas import LunchNotifyRequest
|
| 8 |
from src.core.utils import send_mail_as_user
|
| 9 |
from src.core.models import LunchLocation
|
| 10 |
-
from src.payslip.models import PayslipRequest, PayslipStatus
|
| 11 |
-
from datetime import timedelta, datetime
|
| 12 |
|
| 13 |
LOCATION_ROLE_MAP = {
|
| 14 |
LunchLocation.SOLAR_KITCHEN: "LM - Solar Kitchen",
|
| 15 |
LunchLocation.SARACON_CAMPUS: "LM - Saracon Campus",
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
| 19 |
async def get_lunch_manager_by_location(
|
| 20 |
session: AsyncSession,
|
| 21 |
location: LunchLocation,
|
|
@@ -26,21 +23,20 @@ async def get_lunch_manager_by_location(
|
|
| 26 |
raise HTTPException(400, "Invalid lunch location")
|
| 27 |
|
| 28 |
role = (
|
| 29 |
-
await session.execute(
|
|
|
|
|
|
|
| 30 |
).scalar_one_or_none()
|
| 31 |
|
| 32 |
if not role:
|
| 33 |
raise HTTPException(500, "Lunch manager role missing")
|
| 34 |
|
| 35 |
mapping = (
|
| 36 |
-
(
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
)
|
| 40 |
)
|
| 41 |
-
|
| 42 |
-
.first()
|
| 43 |
-
)
|
| 44 |
|
| 45 |
if not mapping:
|
| 46 |
raise HTTPException(500, "No lunch manager assigned")
|
|
@@ -51,7 +47,6 @@ async def get_lunch_manager_by_location(
|
|
| 51 |
|
| 52 |
return manager
|
| 53 |
|
| 54 |
-
|
| 55 |
def build_lunch_mail_body(
|
| 56 |
user_name: str,
|
| 57 |
email: str,
|
|
@@ -72,42 +67,11 @@ def build_lunch_mail_body(
|
|
| 72 |
f"The user has opted out of lunch."
|
| 73 |
)
|
| 74 |
|
| 75 |
-
|
| 76 |
async def process_lunch_notification(
|
| 77 |
session: AsyncSession,
|
| 78 |
user_id: uuid.UUID,
|
| 79 |
payload: LunchNotifyRequest,
|
| 80 |
):
|
| 81 |
-
|
| 82 |
-
requested_dates = []
|
| 83 |
-
curr = payload.start_date
|
| 84 |
-
while curr <= payload.end_date:
|
| 85 |
-
requested_dates.append(curr.strftime("%Y-%m-%d"))
|
| 86 |
-
curr += timedelta(days=1)
|
| 87 |
-
|
| 88 |
-
# 2. CHECK DUPLICATES: Get all past lunch requests for this user
|
| 89 |
-
# We fetch only the 'target_dates' column to make it fast
|
| 90 |
-
stmt = select(PayslipRequest.target_dates).where(
|
| 91 |
-
PayslipRequest.user_id == user_id,
|
| 92 |
-
PayslipRequest.request_type == "lunch", # Only look at lunch rows
|
| 93 |
-
PayslipRequest.status == PayslipStatus.SENT,
|
| 94 |
-
)
|
| 95 |
-
result = await session.execute(stmt)
|
| 96 |
-
|
| 97 |
-
# Flatten the list of lists into a single set of existing dates
|
| 98 |
-
existing_dates = set()
|
| 99 |
-
for row in result.scalars().all():
|
| 100 |
-
if row: # Check if row is not None
|
| 101 |
-
existing_dates.update(row) # Add all dates from that request
|
| 102 |
-
|
| 103 |
-
# Check if any requested date is already in the database
|
| 104 |
-
duplicates = [d for d in requested_dates if d in existing_dates]
|
| 105 |
-
|
| 106 |
-
if duplicates:
|
| 107 |
-
raise HTTPException(
|
| 108 |
-
status_code=400,
|
| 109 |
-
detail=f"Already sent a request for: {', '.join(duplicates)}",
|
| 110 |
-
)
|
| 111 |
# fetch only needed user fields
|
| 112 |
row = (
|
| 113 |
await session.execute(
|
|
@@ -147,20 +111,11 @@ async def process_lunch_notification(
|
|
| 147 |
body=body,
|
| 148 |
)
|
| 149 |
except HTTPException as e:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
new_req = PayslipRequest(
|
| 157 |
-
user_id=user_id,
|
| 158 |
-
status=PayslipStatus.SENT,
|
| 159 |
-
request_type="lunch", # Mark as lunch
|
| 160 |
-
target_dates=requested_dates, # Save the dates ["2024-02-10"]
|
| 161 |
-
requested_at=datetime.now(),
|
| 162 |
-
)
|
| 163 |
-
session.add(new_req)
|
| 164 |
-
await session.commit()
|
| 165 |
|
| 166 |
return manager.email_id
|
|
|
|
| 7 |
from src.foodcount.schemas import LunchNotifyRequest
|
| 8 |
from src.core.utils import send_mail_as_user
|
| 9 |
from src.core.models import LunchLocation
|
|
|
|
|
|
|
| 10 |
|
| 11 |
LOCATION_ROLE_MAP = {
|
| 12 |
LunchLocation.SOLAR_KITCHEN: "LM - Solar Kitchen",
|
| 13 |
LunchLocation.SARACON_CAMPUS: "LM - Saracon Campus",
|
| 14 |
}
|
| 15 |
|
|
|
|
| 16 |
async def get_lunch_manager_by_location(
|
| 17 |
session: AsyncSession,
|
| 18 |
location: LunchLocation,
|
|
|
|
| 23 |
raise HTTPException(400, "Invalid lunch location")
|
| 24 |
|
| 25 |
role = (
|
| 26 |
+
await session.execute(
|
| 27 |
+
select(Roles).where(Roles.name == role_name)
|
| 28 |
+
)
|
| 29 |
).scalar_one_or_none()
|
| 30 |
|
| 31 |
if not role:
|
| 32 |
raise HTTPException(500, "Lunch manager role missing")
|
| 33 |
|
| 34 |
mapping = (
|
| 35 |
+
await session.execute(
|
| 36 |
+
select(UserTeamsRole)
|
| 37 |
+
.where(UserTeamsRole.role_id == role.id)
|
|
|
|
| 38 |
)
|
| 39 |
+
).scalars().first()
|
|
|
|
|
|
|
| 40 |
|
| 41 |
if not mapping:
|
| 42 |
raise HTTPException(500, "No lunch manager assigned")
|
|
|
|
| 47 |
|
| 48 |
return manager
|
| 49 |
|
|
|
|
| 50 |
def build_lunch_mail_body(
|
| 51 |
user_name: str,
|
| 52 |
email: str,
|
|
|
|
| 67 |
f"The user has opted out of lunch."
|
| 68 |
)
|
| 69 |
|
|
|
|
| 70 |
async def process_lunch_notification(
|
| 71 |
session: AsyncSession,
|
| 72 |
user_id: uuid.UUID,
|
| 73 |
payload: LunchNotifyRequest,
|
| 74 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
# fetch only needed user fields
|
| 76 |
row = (
|
| 77 |
await session.execute(
|
|
|
|
| 111 |
body=body,
|
| 112 |
)
|
| 113 |
except HTTPException as e:
|
| 114 |
+
if e.status_code == 428:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=428,
|
| 117 |
+
detail="Please connect your Gmail account"
|
| 118 |
+
)
|
| 119 |
+
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
return manager.email_id
|
src/main.py
CHANGED
|
@@ -17,16 +17,6 @@ from fastapi.staticfiles import StaticFiles
|
|
| 17 |
|
| 18 |
app = FastAPI(title="Yuvabe App API")
|
| 19 |
|
| 20 |
-
# app.add_middleware(
|
| 21 |
-
# CORSMiddleware,
|
| 22 |
-
# # REMOVE: allow_origins=["*"],
|
| 23 |
-
# # ADD THIS: Regex to match localhost on ANY port
|
| 24 |
-
# allow_origin_regex="https?://(?:localhost|127\.0\.0\.1)(?::\d+)?",
|
| 25 |
-
# allow_credentials=True,
|
| 26 |
-
# allow_methods=["*"],
|
| 27 |
-
# allow_headers=["*"],
|
| 28 |
-
# )
|
| 29 |
-
|
| 30 |
|
| 31 |
@app.on_event("startup")
|
| 32 |
async def on_startup():
|
|
|
|
| 17 |
|
| 18 |
app = FastAPI(title="Yuvabe App API")
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
@app.on_event("startup")
|
| 22 |
async def on_startup():
|
src/payslip/googleservice.py
CHANGED
|
@@ -18,15 +18,8 @@ def exchange_code_for_tokens(code: str):
|
|
| 18 |
"grant_type": "authorization_code",
|
| 19 |
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
|
| 20 |
}
|
| 21 |
-
print(f"🔄 Exchanging code with Redirect URI: {settings.GOOGLE_REDIRECT_URI}")
|
| 22 |
-
response = requests.post(settings.TOKEN_URL, data=data)
|
| 23 |
-
if response.status_code != 200:
|
| 24 |
-
error_text = response.text
|
| 25 |
-
print(
|
| 26 |
-
f"❌ GOOGLE TOKEN ERROR: {error_text}"
|
| 27 |
-
) # This will show in your Hugging Face logs
|
| 28 |
-
raise HTTPException(status_code=400, detail=f"Google Error: {error_text}")
|
| 29 |
|
|
|
|
| 30 |
return response.json()
|
| 31 |
|
| 32 |
|
|
|
|
| 18 |
"grant_type": "authorization_code",
|
| 19 |
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
|
| 20 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
response = requests.post(settings.TOKEN_URL, data=data)
|
| 23 |
return response.json()
|
| 24 |
|
| 25 |
|
src/payslip/models.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 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 |
|
|
@@ -14,11 +14,6 @@ class PayslipStatus(str, Enum):
|
|
| 14 |
FAILED = "Failed"
|
| 15 |
|
| 16 |
|
| 17 |
-
class RequestType(str, Enum):
|
| 18 |
-
PAYSLIP = "payslip"
|
| 19 |
-
LUNCH = "lunch"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
class PayslipRequest(SQLModel, table=True):
|
| 23 |
__tablename__ = "payslip_requests"
|
| 24 |
|
|
@@ -39,9 +34,3 @@ class PayslipRequest(SQLModel, table=True):
|
|
| 39 |
refresh_token: Optional[str] = None
|
| 40 |
|
| 41 |
error_message: Optional[str] = None
|
| 42 |
-
|
| 43 |
-
request_type: str = Field(default=RequestType.PAYSLIP)
|
| 44 |
-
|
| 45 |
-
# Stores list of dates like ["2024-01-01", "2024-01-02"]
|
| 46 |
-
# We use JSON type to store arrays in Postgres
|
| 47 |
-
target_dates: Optional[List[str]] = Field(default=None, sa_column=Column(JSON))
|
|
|
|
| 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 |
|
|
|
|
| 14 |
FAILED = "Failed"
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
class PayslipRequest(SQLModel, table=True):
|
| 18 |
__tablename__ = "payslip_requests"
|
| 19 |
|
|
|
|
| 34 |
refresh_token: Optional[str] = None
|
| 35 |
|
| 36 |
error_message: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/payslip/router.py
CHANGED
|
@@ -24,18 +24,11 @@ from src.payslip.utils import encrypt_token
|
|
| 24 |
router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
|
| 25 |
|
| 26 |
|
| 27 |
-
# Add platform parameter with default="mobile"
|
| 28 |
@router.get("/gmail/connect-url")
|
| 29 |
-
async def gmail_connect_url(
|
| 30 |
-
user_id: uuid.UUID, platform: str = "mobile", redirect_path: str = "/payslip"
|
| 31 |
-
):
|
| 32 |
"""
|
| 33 |
-
Returns the Google OAuth URL.
|
| 34 |
-
State format: "user_id|platform" (e.g., "123e4567-e89b...|web")
|
| 35 |
"""
|
| 36 |
-
# 1. Combine ID and Platform into one state string
|
| 37 |
-
combined_state = f"{user_id}|{platform}|{redirect_path}"
|
| 38 |
-
|
| 39 |
params = {
|
| 40 |
"client_id": settings.GOOGLE_CLIENT_ID,
|
| 41 |
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
|
|
@@ -43,7 +36,7 @@ async def gmail_connect_url(
|
|
| 43 |
"scope": "openid email profile https://www.googleapis.com/auth/gmail.send",
|
| 44 |
"access_type": "offline",
|
| 45 |
"prompt": "consent",
|
| 46 |
-
"state":
|
| 47 |
}
|
| 48 |
return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
|
| 49 |
|
|
@@ -54,90 +47,34 @@ async def gmail_callback(
|
|
| 54 |
):
|
| 55 |
from fastapi.responses import RedirectResponse
|
| 56 |
|
| 57 |
-
|
| 58 |
-
# Try to split "uuid|platform". If it fails (old app version), assume mobile.
|
| 59 |
-
parts = state.split("|")
|
| 60 |
-
|
| 61 |
-
if len(parts) >= 3:
|
| 62 |
-
user_id_str = parts[0]
|
| 63 |
-
platform = parts[1]
|
| 64 |
-
redirect_path = parts[2]
|
| 65 |
-
elif len(parts) == 2:
|
| 66 |
-
# Backward compatibility (old mobile app versions)
|
| 67 |
-
user_id_str = parts[0]
|
| 68 |
-
platform = parts[1]
|
| 69 |
-
redirect_path = "/payslip"
|
| 70 |
-
else:
|
| 71 |
-
# Fallback
|
| 72 |
-
user_id_str = state
|
| 73 |
-
platform = "mobile"
|
| 74 |
-
redirect_path = "/payslip"
|
| 75 |
-
|
| 76 |
-
try:
|
| 77 |
-
user_id = uuid.UUID(user_id_str)
|
| 78 |
-
except ValueError:
|
| 79 |
-
return RedirectResponse(
|
| 80 |
-
"yuvabe://gmail/callback?success=false&error=invalid_state"
|
| 81 |
-
)
|
| 82 |
-
# --- 2. DEFINE BASE URL BASED ON PLATFORM ---
|
| 83 |
-
if platform == "web":
|
| 84 |
-
# Point to your React Web App URL (Localhost or Production)
|
| 85 |
-
# Ideally, put "http://localhost:5173" in your settings.py as WEB_BASE_URL
|
| 86 |
-
base_redirect = f"{settings.WEB_FRONTEND_URL}{redirect_path}"
|
| 87 |
-
else:
|
| 88 |
-
# Default Mobile Deep Link
|
| 89 |
-
base_redirect = "yuvabe://gmail/callback"
|
| 90 |
-
|
| 91 |
-
# Helper to build the final URL cleanly
|
| 92 |
-
def build_redirect(success: bool, error: str = None, message: str = None):
|
| 93 |
-
params = {"success": str(success).lower()}
|
| 94 |
-
if error:
|
| 95 |
-
params["error"] = error
|
| 96 |
-
if message:
|
| 97 |
-
params["message"] = message
|
| 98 |
-
return f"{base_redirect}?{urlencode(params)}"
|
| 99 |
-
|
| 100 |
-
# --- 3. EXISTING LOGIC (Updated to use build_redirect) ---
|
| 101 |
user = await session.get(Users, user_id)
|
|
|
|
| 102 |
if not user:
|
| 103 |
return RedirectResponse(
|
| 104 |
-
|
| 105 |
)
|
| 106 |
|
| 107 |
try:
|
| 108 |
token_data = exchange_code_for_tokens(code)
|
| 109 |
google_email = extract_email_from_id_token(token_data["id_token"])
|
| 110 |
-
except Exception
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
print("------------------------------------------------")
|
| 117 |
-
|
| 118 |
-
# Pass the actual error message to the frontend for debugging
|
| 119 |
-
error_msg = str(e).replace("400: ", "") # Clean up message slightly
|
| 120 |
-
return RedirectResponse(build_redirect(False, "invalid_code", error_msg))
|
| 121 |
-
|
| 122 |
-
# Gmail Mismatch
|
| 123 |
if google_email.lower() != user.email_id.lower():
|
| 124 |
return RedirectResponse(
|
| 125 |
-
|
| 126 |
-
False,
|
| 127 |
-
"email_mismatch",
|
| 128 |
-
"Google account does not match registered email",
|
| 129 |
-
)
|
| 130 |
)
|
| 131 |
|
| 132 |
refresh_token = token_data.get("refresh_token")
|
| 133 |
if not refresh_token:
|
| 134 |
return RedirectResponse(
|
| 135 |
-
|
| 136 |
-
False, "no_refresh_token", "No refresh token returned from Google"
|
| 137 |
-
)
|
| 138 |
)
|
| 139 |
|
| 140 |
-
# ... (Database saving logic stays exactly the same) ...
|
| 141 |
q = (
|
| 142 |
select(PayslipRequest)
|
| 143 |
.where(PayslipRequest.user_id == user_id)
|
|
@@ -159,9 +96,8 @@ async def gmail_callback(
|
|
| 159 |
|
| 160 |
await session.commit()
|
| 161 |
|
| 162 |
-
# Success Redirect
|
| 163 |
return RedirectResponse(
|
| 164 |
-
|
| 165 |
)
|
| 166 |
|
| 167 |
|
|
|
|
| 24 |
router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
|
| 25 |
|
| 26 |
|
|
|
|
| 27 |
@router.get("/gmail/connect-url")
|
| 28 |
+
async def gmail_connect_url(user_id: uuid.UUID):
|
|
|
|
|
|
|
| 29 |
"""
|
| 30 |
+
Returns the Google OAuth URL for the frontend to open in InAppBrowser.
|
|
|
|
| 31 |
"""
|
|
|
|
|
|
|
|
|
|
| 32 |
params = {
|
| 33 |
"client_id": settings.GOOGLE_CLIENT_ID,
|
| 34 |
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
|
|
|
|
| 36 |
"scope": "openid email profile https://www.googleapis.com/auth/gmail.send",
|
| 37 |
"access_type": "offline",
|
| 38 |
"prompt": "consent",
|
| 39 |
+
"state": str(user_id),
|
| 40 |
}
|
| 41 |
return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
|
| 42 |
|
|
|
|
| 47 |
):
|
| 48 |
from fastapi.responses import RedirectResponse
|
| 49 |
|
| 50 |
+
user_id = uuid.UUID(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
user = await session.get(Users, user_id)
|
| 52 |
+
|
| 53 |
if not user:
|
| 54 |
return RedirectResponse(
|
| 55 |
+
"yuvabe://gmail/callback?success=false&error=user_not_found&message=No such user exists"
|
| 56 |
)
|
| 57 |
|
| 58 |
try:
|
| 59 |
token_data = exchange_code_for_tokens(code)
|
| 60 |
google_email = extract_email_from_id_token(token_data["id_token"])
|
| 61 |
+
except Exception:
|
| 62 |
+
return RedirectResponse(
|
| 63 |
+
"yuvabe://gmail/callback?success=false&error=invalid_code&message=OAuth code exchange failed"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# --- GMAIL MISMATCH ERROR ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if google_email.lower() != user.email_id.lower():
|
| 68 |
return RedirectResponse(
|
| 69 |
+
"yuvabe://gmail/callback?success=false&error=email_mismatch&message=Google account does not match registered email"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
)
|
| 71 |
|
| 72 |
refresh_token = token_data.get("refresh_token")
|
| 73 |
if not refresh_token:
|
| 74 |
return RedirectResponse(
|
| 75 |
+
"yuvabe://gmail/callback?success=false&error=no_refresh_token&message=No refresh token returned from Google"
|
|
|
|
|
|
|
| 76 |
)
|
| 77 |
|
|
|
|
| 78 |
q = (
|
| 79 |
select(PayslipRequest)
|
| 80 |
.where(PayslipRequest.user_id == user_id)
|
|
|
|
| 96 |
|
| 97 |
await session.commit()
|
| 98 |
|
|
|
|
| 99 |
return RedirectResponse(
|
| 100 |
+
"yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
|
| 101 |
)
|
| 102 |
|
| 103 |
|
src/payslip/service.py
CHANGED
|
@@ -22,19 +22,13 @@ from src.payslip.utils import encrypt_token
|
|
| 22 |
async def user_team_name(session: AsyncSession, user_id):
|
| 23 |
"""Return user's team name."""
|
| 24 |
q = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
|
| 25 |
-
|
| 26 |
|
| 27 |
-
if not
|
| 28 |
-
return "Unknown Team"
|
| 29 |
-
|
| 30 |
-
team_ids = [m.team_id for m in mappings]
|
| 31 |
-
q_teams = select(Teams.name).where(Teams.id.in_(team_ids))
|
| 32 |
-
team_names = (await session.execute(q_teams)).scalars().all()
|
| 33 |
-
|
| 34 |
-
if not team_names:
|
| 35 |
return "Unknown Team"
|
| 36 |
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
async def one_request_per_day(session: AsyncSession, user_id):
|
|
@@ -49,7 +43,6 @@ async def one_request_per_day(session: AsyncSession, user_id):
|
|
| 49 |
PayslipRequest.user_id == user_id,
|
| 50 |
PayslipRequest.requested_at >= today_start,
|
| 51 |
PayslipRequest.status != PayslipStatus.PENDING,
|
| 52 |
-
PayslipRequest.request_type == "payslip"
|
| 53 |
)
|
| 54 |
|
| 55 |
result = await session.execute(q)
|
|
@@ -80,7 +73,7 @@ async def get_latest_payslip_row(session: AsyncSession, user_id):
|
|
| 80 |
"""
|
| 81 |
q = (
|
| 82 |
select(PayslipRequest)
|
| 83 |
-
.where(PayslipRequest.user_id == user_id
|
| 84 |
.order_by(PayslipRequest.requested_at.desc())
|
| 85 |
)
|
| 86 |
return (await session.execute(q)).scalar_one_or_none()
|
|
|
|
| 22 |
async def user_team_name(session: AsyncSession, user_id):
|
| 23 |
"""Return user's team name."""
|
| 24 |
q = select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
|
| 25 |
+
mapping = (await session.execute(q)).scalar_one_or_none()
|
| 26 |
|
| 27 |
+
if not mapping:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return "Unknown Team"
|
| 29 |
|
| 30 |
+
team = await session.get(Teams, mapping.team_id)
|
| 31 |
+
return team.name if team else "Unknown Team"
|
| 32 |
|
| 33 |
|
| 34 |
async def one_request_per_day(session: AsyncSession, user_id):
|
|
|
|
| 43 |
PayslipRequest.user_id == user_id,
|
| 44 |
PayslipRequest.requested_at >= today_start,
|
| 45 |
PayslipRequest.status != PayslipStatus.PENDING,
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
result = await session.execute(q)
|
|
|
|
| 73 |
"""
|
| 74 |
q = (
|
| 75 |
select(PayslipRequest)
|
| 76 |
+
.where(PayslipRequest.user_id == user_id)
|
| 77 |
.order_by(PayslipRequest.requested_at.desc())
|
| 78 |
)
|
| 79 |
return (await session.execute(q)).scalar_one_or_none()
|
src/profile/notify.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
from src.notifications.service import get_user_device_tokens
|
| 2 |
from src.notifications.fcm import send_fcm
|
| 3 |
-
from src.core.models import Users
|
| 4 |
|
| 5 |
|
| 6 |
def ensure_list(value):
|
|
@@ -45,7 +44,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
|
|
| 45 |
"type": "leave_request",
|
| 46 |
"screen": "MentorApproval",
|
| 47 |
"leave_id": str(leave.id),
|
| 48 |
-
"url": f"/mentor-approval/{leave.id}",
|
| 49 |
},
|
| 50 |
priority="high",
|
| 51 |
)
|
|
@@ -68,7 +66,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
|
|
| 68 |
"type": "leave_request",
|
| 69 |
"screen": "LeaveDetails",
|
| 70 |
"leave_id": str(leave.id),
|
| 71 |
-
"url": f"/leave-details/{leave.id}",
|
| 72 |
},
|
| 73 |
priority="high",
|
| 74 |
)
|
|
@@ -78,10 +75,6 @@ async def send_leave_request_notification(session, user, leave, mentor_ids, lead
|
|
| 78 |
# SEND TO USER + TEAM LEAD
|
| 79 |
# -------------------------------
|
| 80 |
async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
|
| 81 |
-
|
| 82 |
-
user = await session.get(Users, leave.user_id)
|
| 83 |
-
# Fallback to "Unknown" if the user isn't found for some reason
|
| 84 |
-
user_display_name = user.user_name if user else "Unknown User"
|
| 85 |
title = "Leave status"
|
| 86 |
body = f"Your leave was {leave.status.lower()} by {mentor_name}"
|
| 87 |
|
|
@@ -104,7 +97,6 @@ async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
|
|
| 104 |
"type": "leave_status",
|
| 105 |
"screen": "LeaveDetails",
|
| 106 |
"leave_id": str(leave.id),
|
| 107 |
-
"url": f"/leave-details/{leave.id}",
|
| 108 |
},
|
| 109 |
priority="high",
|
| 110 |
)
|
|
@@ -113,12 +105,11 @@ async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
|
|
| 113 |
await send_fcm(
|
| 114 |
lead_tokens,
|
| 115 |
title,
|
| 116 |
-
f"Leave {leave.status} for user {
|
| 117 |
{
|
| 118 |
"type": "lead_update",
|
| 119 |
"screen": "LeaveDetails",
|
| 120 |
"leave_id": str(leave.id),
|
| 121 |
-
"url": f"/leave-details/{leave.id}",
|
| 122 |
},
|
| 123 |
priority="high",
|
| 124 |
)
|
|
|
|
| 1 |
from src.notifications.service import get_user_device_tokens
|
| 2 |
from src.notifications.fcm import send_fcm
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
def ensure_list(value):
|
|
|
|
| 44 |
"type": "leave_request",
|
| 45 |
"screen": "MentorApproval",
|
| 46 |
"leave_id": str(leave.id),
|
|
|
|
| 47 |
},
|
| 48 |
priority="high",
|
| 49 |
)
|
|
|
|
| 66 |
"type": "leave_request",
|
| 67 |
"screen": "LeaveDetails",
|
| 68 |
"leave_id": str(leave.id),
|
|
|
|
| 69 |
},
|
| 70 |
priority="high",
|
| 71 |
)
|
|
|
|
| 75 |
# SEND TO USER + TEAM LEAD
|
| 76 |
# -------------------------------
|
| 77 |
async def send_leave_status_notification(session, leave, mentor_name, lead_ids):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
title = "Leave status"
|
| 79 |
body = f"Your leave was {leave.status.lower()} by {mentor_name}"
|
| 80 |
|
|
|
|
| 97 |
"type": "leave_status",
|
| 98 |
"screen": "LeaveDetails",
|
| 99 |
"leave_id": str(leave.id),
|
|
|
|
| 100 |
},
|
| 101 |
priority="high",
|
| 102 |
)
|
|
|
|
| 105 |
await send_fcm(
|
| 106 |
lead_tokens,
|
| 107 |
title,
|
| 108 |
+
f"Leave {leave.status} for user {leave.user_id}",
|
| 109 |
{
|
| 110 |
"type": "lead_update",
|
| 111 |
"screen": "LeaveDetails",
|
| 112 |
"leave_id": str(leave.id),
|
|
|
|
| 113 |
},
|
| 114 |
priority="high",
|
| 115 |
)
|
src/profile/router.py
CHANGED
|
@@ -119,8 +119,8 @@ async def get_leave_balance(
|
|
| 119 |
)
|
| 120 |
results = (await session.exec(stmt)).all()
|
| 121 |
|
| 122 |
-
sick_used = sum(
|
| 123 |
-
casual_used = sum(
|
| 124 |
|
| 125 |
sick_remaining = SICK_LIMIT - sick_used
|
| 126 |
casual_remaining = CASUAL_LIMIT - casual_used
|
|
@@ -145,10 +145,9 @@ async def get_leave_balance_for_user(
|
|
| 145 |
)
|
| 146 |
results = (await session.exec(stmt)).all()
|
| 147 |
|
| 148 |
-
sick_used = sum(
|
| 149 |
-
casual_used = sum(
|
| 150 |
|
| 151 |
-
|
| 152 |
sick_remaining = SICK_LIMIT - sick_used
|
| 153 |
casual_remaining = CASUAL_LIMIT - casual_used
|
| 154 |
|
|
@@ -175,7 +174,7 @@ async def list_notifications(
|
|
| 175 |
| (Leave.mentor_id == user_id)
|
| 176 |
| (Leave.lead_id == user_id)
|
| 177 |
)
|
| 178 |
-
.order_by(
|
| 179 |
)
|
| 180 |
|
| 181 |
results = (await session.exec(stmt)).all()
|
|
@@ -312,6 +311,8 @@ async def mentor_pending_leaves(
|
|
| 312 |
):
|
| 313 |
mentor_uuid = uuid.UUID(mentor_id)
|
| 314 |
|
|
|
|
|
|
|
| 315 |
mentor_team = (
|
| 316 |
await session.exec(
|
| 317 |
select(UserTeamsRole).where(UserTeamsRole.user_id == mentor_uuid)
|
|
@@ -363,9 +364,7 @@ async def mentor_pending_leaves(
|
|
| 363 |
lead_id=str(leave.lead_id),
|
| 364 |
user_name=user_name,
|
| 365 |
updated_at=(leave.updated_at.isoformat() if leave.updated_at else None),
|
| 366 |
-
requested_at=(
|
| 367 |
-
leave.requested_at.isoformat() if leave.requested_at else None
|
| 368 |
-
),
|
| 369 |
)
|
| 370 |
)
|
| 371 |
|
|
@@ -574,7 +573,6 @@ async def get_profile_details(
|
|
| 574 |
data={
|
| 575 |
"name": user.user_name,
|
| 576 |
"email": user.email_id,
|
| 577 |
-
"nick_name": user.nick_name,
|
| 578 |
"team_name": team.name,
|
| 579 |
"lead_label": lead_label, # 🔥 Frontend uses this
|
| 580 |
"lead_name": final_lead_name, # 🔥 Frontend uses this
|
|
@@ -709,9 +707,6 @@ async def update_profile(
|
|
| 709 |
if body.email is not None:
|
| 710 |
user.email_id = body.email
|
| 711 |
|
| 712 |
-
if body.nick_name is not None:
|
| 713 |
-
user.nick_name = body.nick_name
|
| 714 |
-
|
| 715 |
if body.dob is not None:
|
| 716 |
dob_str = body.dob.replace(".", "-") # convert dots to dashes
|
| 717 |
try:
|
|
@@ -756,7 +751,6 @@ async def update_profile(
|
|
| 756 |
"id": str(user.id),
|
| 757 |
"name": user.user_name,
|
| 758 |
"email": user.email_id,
|
| 759 |
-
"nick_name": user.nick_name,
|
| 760 |
"dob": getattr(user, "dob", None),
|
| 761 |
"address": getattr(user, "address", None),
|
| 762 |
}
|
|
|
|
| 119 |
)
|
| 120 |
results = (await session.exec(stmt)).all()
|
| 121 |
|
| 122 |
+
sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
|
| 123 |
+
casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
|
| 124 |
|
| 125 |
sick_remaining = SICK_LIMIT - sick_used
|
| 126 |
casual_remaining = CASUAL_LIMIT - casual_used
|
|
|
|
| 145 |
)
|
| 146 |
results = (await session.exec(stmt)).all()
|
| 147 |
|
| 148 |
+
sick_used = sum(1 for l in results if l.leave_type == LeaveType.SICK)
|
| 149 |
+
casual_used = sum(1 for l in results if l.leave_type == LeaveType.CASUAL)
|
| 150 |
|
|
|
|
| 151 |
sick_remaining = SICK_LIMIT - sick_used
|
| 152 |
casual_remaining = CASUAL_LIMIT - casual_used
|
| 153 |
|
|
|
|
| 174 |
| (Leave.mentor_id == user_id)
|
| 175 |
| (Leave.lead_id == user_id)
|
| 176 |
)
|
| 177 |
+
.order_by(desc(Leave.updated_at))
|
| 178 |
)
|
| 179 |
|
| 180 |
results = (await session.exec(stmt)).all()
|
|
|
|
| 311 |
):
|
| 312 |
mentor_uuid = uuid.UUID(mentor_id)
|
| 313 |
|
| 314 |
+
|
| 315 |
+
|
| 316 |
mentor_team = (
|
| 317 |
await session.exec(
|
| 318 |
select(UserTeamsRole).where(UserTeamsRole.user_id == mentor_uuid)
|
|
|
|
| 364 |
lead_id=str(leave.lead_id),
|
| 365 |
user_name=user_name,
|
| 366 |
updated_at=(leave.updated_at.isoformat() if leave.updated_at else None),
|
| 367 |
+
requested_at=leave.requested_at.isoformat() if leave.requested_at else None,
|
|
|
|
|
|
|
| 368 |
)
|
| 369 |
)
|
| 370 |
|
|
|
|
| 573 |
data={
|
| 574 |
"name": user.user_name,
|
| 575 |
"email": user.email_id,
|
|
|
|
| 576 |
"team_name": team.name,
|
| 577 |
"lead_label": lead_label, # 🔥 Frontend uses this
|
| 578 |
"lead_name": final_lead_name, # 🔥 Frontend uses this
|
|
|
|
| 707 |
if body.email is not None:
|
| 708 |
user.email_id = body.email
|
| 709 |
|
|
|
|
|
|
|
|
|
|
| 710 |
if body.dob is not None:
|
| 711 |
dob_str = body.dob.replace(".", "-") # convert dots to dashes
|
| 712 |
try:
|
|
|
|
| 751 |
"id": str(user.id),
|
| 752 |
"name": user.user_name,
|
| 753 |
"email": user.email_id,
|
|
|
|
| 754 |
"dob": getattr(user, "dob", None),
|
| 755 |
"address": getattr(user, "address", None),
|
| 756 |
}
|
src/profile/schemas.py
CHANGED
|
@@ -111,7 +111,7 @@ class UpdateProfileRequest(BaseModel):
|
|
| 111 |
email: Optional[EmailStr] = None
|
| 112 |
dob: Optional[str] = None
|
| 113 |
address: Optional[str] = None
|
| 114 |
-
|
| 115 |
current_password: Optional[str] = None
|
| 116 |
new_password: Optional[str] = None
|
| 117 |
|
|
|
|
| 111 |
email: Optional[EmailStr] = None
|
| 112 |
dob: Optional[str] = None
|
| 113 |
address: Optional[str] = None
|
| 114 |
+
|
| 115 |
current_password: Optional[str] = None
|
| 116 |
new_password: Optional[str] = None
|
| 117 |
|
src/profile/service.py
CHANGED
|
@@ -14,7 +14,6 @@ from src.profile.models import (
|
|
| 14 |
Leave,
|
| 15 |
UserDevices,
|
| 16 |
)
|
| 17 |
-
from sqlalchemy import select, and_, or_, not_
|
| 18 |
from src.profile.notify import send_leave_request_notification
|
| 19 |
|
| 20 |
from src.profile.schemas import CreateLeaveRequest, LeaveStatus, ApproveRejectRequest
|
|
@@ -39,7 +38,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
|
|
| 39 |
await session.exec(
|
| 40 |
select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
|
| 41 |
)
|
| 42 |
-
).
|
| 43 |
|
| 44 |
if not user_team:
|
| 45 |
raise ValueError("User has no team mapping")
|
|
@@ -47,10 +46,10 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
|
|
| 47 |
# 2) Get Mentor role
|
| 48 |
mentor_role = (
|
| 49 |
await session.exec(select(Roles).where(Roles.name == "Mentor"))
|
| 50 |
-
).
|
| 51 |
sub_mentor_role = (
|
| 52 |
await session.exec(select(Roles).where(Roles.name == "Sub Mentor"))
|
| 53 |
-
).
|
| 54 |
|
| 55 |
if not mentor_role and not sub_mentor_role:
|
| 56 |
raise ValueError("No Mentor / Sub Mentor roles defined in system")
|
|
@@ -64,7 +63,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
|
|
| 64 |
# 3) Get Team Lead role
|
| 65 |
lead_role = (
|
| 66 |
await session.exec(select(Roles).where(Roles.name == "Team Lead"))
|
| 67 |
-
).
|
| 68 |
|
| 69 |
# 4) Find mentor in same team
|
| 70 |
mentor_users = (
|
|
@@ -74,7 +73,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
|
|
| 74 |
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 75 |
.where(UserTeamsRole.role_id.in_(mentor_role_ids))
|
| 76 |
)
|
| 77 |
-
).
|
| 78 |
|
| 79 |
if not mentor_users:
|
| 80 |
raise ValueError("No Mentor or Sub Mentor found in user's team")
|
|
@@ -89,7 +88,7 @@ async def _get_team_roles(session: AsyncSession, user_id: uuid.UUID):
|
|
| 89 |
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 90 |
.where(UserTeamsRole.role_id == lead_role.id)
|
| 91 |
)
|
| 92 |
-
).
|
| 93 |
|
| 94 |
return mentor_users, lead_users
|
| 95 |
|
|
@@ -112,26 +111,6 @@ async def _get_tokens_for_users(
|
|
| 112 |
|
| 113 |
|
| 114 |
async def create_leave(session, user_id, body):
|
| 115 |
-
|
| 116 |
-
overlap_query = select(Leave).where(
|
| 117 |
-
and_(
|
| 118 |
-
Leave.user_id == user_id,
|
| 119 |
-
# We use a list of strings if LeaveStatus is an Enum/String
|
| 120 |
-
Leave.status.notin_([LeaveStatus.REJECTED, LeaveStatus.CANCELLED]),
|
| 121 |
-
Leave.from_date <= body.to_date,
|
| 122 |
-
Leave.to_date >= body.from_date,
|
| 123 |
-
)
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
# Use .exec() to match your existing style
|
| 127 |
-
result = await session.exec(overlap_query)
|
| 128 |
-
existing_overlap = result.scalars().first()
|
| 129 |
-
|
| 130 |
-
if existing_overlap:
|
| 131 |
-
raise HTTPException(
|
| 132 |
-
status_code=400,
|
| 133 |
-
detail=f"You have already submitted a leave request that covers {body.from_date} to {body.to_date}. Please check your leave history.",
|
| 134 |
-
)
|
| 135 |
# Get the user
|
| 136 |
user = await session.get(Users, user_id)
|
| 137 |
|
|
|
|
| 14 |
Leave,
|
| 15 |
UserDevices,
|
| 16 |
)
|
|
|
|
| 17 |
from src.profile.notify import send_leave_request_notification
|
| 18 |
|
| 19 |
from src.profile.schemas import CreateLeaveRequest, LeaveStatus, ApproveRejectRequest
|
|
|
|
| 38 |
await session.exec(
|
| 39 |
select(UserTeamsRole).where(UserTeamsRole.user_id == user_id)
|
| 40 |
)
|
| 41 |
+
).first()
|
| 42 |
|
| 43 |
if not user_team:
|
| 44 |
raise ValueError("User has no team mapping")
|
|
|
|
| 46 |
# 2) Get Mentor role
|
| 47 |
mentor_role = (
|
| 48 |
await session.exec(select(Roles).where(Roles.name == "Mentor"))
|
| 49 |
+
).first()
|
| 50 |
sub_mentor_role = (
|
| 51 |
await session.exec(select(Roles).where(Roles.name == "Sub Mentor"))
|
| 52 |
+
).first()
|
| 53 |
|
| 54 |
if not mentor_role and not sub_mentor_role:
|
| 55 |
raise ValueError("No Mentor / Sub Mentor roles defined in system")
|
|
|
|
| 63 |
# 3) Get Team Lead role
|
| 64 |
lead_role = (
|
| 65 |
await session.exec(select(Roles).where(Roles.name == "Team Lead"))
|
| 66 |
+
).first()
|
| 67 |
|
| 68 |
# 4) Find mentor in same team
|
| 69 |
mentor_users = (
|
|
|
|
| 73 |
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 74 |
.where(UserTeamsRole.role_id.in_(mentor_role_ids))
|
| 75 |
)
|
| 76 |
+
).all()
|
| 77 |
|
| 78 |
if not mentor_users:
|
| 79 |
raise ValueError("No Mentor or Sub Mentor found in user's team")
|
|
|
|
| 88 |
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 89 |
.where(UserTeamsRole.role_id == lead_role.id)
|
| 90 |
)
|
| 91 |
+
).all()
|
| 92 |
|
| 93 |
return mentor_users, lead_users
|
| 94 |
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
async def create_leave(session, user_id, body):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# Get the user
|
| 115 |
user = await session.get(Users, user_id)
|
| 116 |
|