Spaces:
Sleeping
Sleeping
fix:remove fields
Browse files- .github/workflows/notify.yml +4 -4
- alembic/versions/3380d93055a7_delete_posts_comments_likes_table.py +74 -0
- alembic/versions/a34f13019598_drop_platform_model.py +35 -0
- src/chatbot/embedding.py +0 -4
- src/chatbot/router.py +4 -2
- src/core/__init__.py +0 -1
- src/core/models.py +5 -0
- src/core/router.py +30 -0
- src/core/schemas.py +5 -1
- src/home/router.py +2 -1
- src/main.py +2 -1
- src/notifications/schemas.py +1 -3
- src/notifications/service.py +2 -4
- src/payslip/router.py +20 -57
- src/payslip/service.py +5 -2
- src/payslip/utils.py +17 -0
- src/profile/models.py +0 -2
- src/profile/utils.py +40 -22
.github/workflows/notify.yml
CHANGED
|
@@ -2,8 +2,8 @@ name: Daily Emotion Check-In Notifications
|
|
| 2 |
|
| 3 |
on:
|
| 4 |
schedule:
|
| 5 |
-
- cron: "30 3 * * *"
|
| 6 |
-
- cron: "15 11 * * *"
|
| 7 |
workflow_dispatch: {}
|
| 8 |
|
| 9 |
jobs:
|
|
@@ -13,7 +13,7 @@ jobs:
|
|
| 13 |
steps:
|
| 14 |
- name: Send morning notification
|
| 15 |
run: |
|
| 16 |
-
curl -X POST "
|
| 17 |
-H "accept: application/json" \
|
| 18 |
-H "Content-Type: application/json" \
|
| 19 |
-d '{
|
|
@@ -31,7 +31,7 @@ jobs:
|
|
| 31 |
steps:
|
| 32 |
- name: Send evening notification
|
| 33 |
run: |
|
| 34 |
-
curl -X POST "
|
| 35 |
-H "accept: application/json" \
|
| 36 |
-H "Content-Type: application/json" \
|
| 37 |
-d '{
|
|
|
|
| 2 |
|
| 3 |
on:
|
| 4 |
schedule:
|
| 5 |
+
- cron: "30 3 * * *" # 9 AM IST
|
| 6 |
+
- cron: "15 11 * * *" # 4:45 PM IST
|
| 7 |
workflow_dispatch: {}
|
| 8 |
|
| 9 |
jobs:
|
|
|
|
| 13 |
steps:
|
| 14 |
- name: Send morning notification
|
| 15 |
run: |
|
| 16 |
+
curl -X POST "${{secrets.NOTIFY_URL}}" \
|
| 17 |
-H "accept: application/json" \
|
| 18 |
-H "Content-Type: application/json" \
|
| 19 |
-d '{
|
|
|
|
| 31 |
steps:
|
| 32 |
- name: Send evening notification
|
| 33 |
run: |
|
| 34 |
+
curl -X POST "${{secrets.NOTIFY_URL}}" \
|
| 35 |
-H "accept: application/json" \
|
| 36 |
-H "Content-Type: application/json" \
|
| 37 |
-d '{
|
alembic/versions/3380d93055a7_delete_posts_comments_likes_table.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""delete posts,comments,likes table
|
| 2 |
+
|
| 3 |
+
Revision ID: 3380d93055a7
|
| 4 |
+
Revises: 217db60578fa
|
| 5 |
+
Create Date: 2025-12-06 21:27:45.958044
|
| 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 = '3380d93055a7'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = '217db60578fa'
|
| 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("likes_post_id_fkey", "likes", type_="foreignkey")
|
| 26 |
+
op.drop_constraint("comments_post_id_fkey", "comments", type_="foreignkey")
|
| 27 |
+
op.drop_table("posts")
|
| 28 |
+
op.drop_table("likes")
|
| 29 |
+
op.drop_table("comments")
|
| 30 |
+
op.alter_column('app_version', 'apk_download_link',
|
| 31 |
+
existing_type=sa.VARCHAR(),
|
| 32 |
+
nullable=False)
|
| 33 |
+
# ### end Alembic commands ###
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def downgrade() -> None:
|
| 37 |
+
"""Downgrade schema."""
|
| 38 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 39 |
+
op.alter_column('app_version', 'apk_download_link',
|
| 40 |
+
existing_type=sa.VARCHAR(),
|
| 41 |
+
nullable=True)
|
| 42 |
+
op.create_table('comments',
|
| 43 |
+
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
| 44 |
+
sa.Column('post_id', sa.UUID(), autoincrement=False, nullable=False),
|
| 45 |
+
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
|
| 46 |
+
sa.Column('comment', sa.VARCHAR(), autoincrement=False, nullable=False),
|
| 47 |
+
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
| 48 |
+
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], name=op.f('comments_post_id_fkey'), ondelete='CASCADE'),
|
| 49 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('comments_user_id_fkey'), ondelete='CASCADE'),
|
| 50 |
+
sa.PrimaryKeyConstraint('id', name=op.f('comments_pkey'))
|
| 51 |
+
)
|
| 52 |
+
op.create_table('likes',
|
| 53 |
+
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
| 54 |
+
sa.Column('post_id', sa.UUID(), autoincrement=False, nullable=False),
|
| 55 |
+
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
|
| 56 |
+
sa.Column('liked_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
| 57 |
+
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], name=op.f('likes_post_id_fkey'), ondelete='CASCADE'),
|
| 58 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('likes_user_id_fkey'), ondelete='CASCADE'),
|
| 59 |
+
sa.PrimaryKeyConstraint('id', name=op.f('likes_pkey')),
|
| 60 |
+
sa.UniqueConstraint('user_id', 'post_id', name=op.f('likes_user_id_post_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
| 61 |
+
)
|
| 62 |
+
op.create_table('posts',
|
| 63 |
+
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
| 64 |
+
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
|
| 65 |
+
sa.Column('type', postgresql.ENUM('BIRTHDAY', 'NOTICE', 'BANNER', 'JOB_REQUEST', name='posttype'), autoincrement=False, nullable=False),
|
| 66 |
+
sa.Column('category', postgresql.ENUM('TEAM', 'GLOBAL', name='postcategory'), autoincrement=False, nullable=False),
|
| 67 |
+
sa.Column('caption', sa.VARCHAR(), autoincrement=False, nullable=True),
|
| 68 |
+
sa.Column('image', sa.VARCHAR(), autoincrement=False, nullable=True),
|
| 69 |
+
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
| 70 |
+
sa.Column('edited_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
| 71 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('posts_user_id_fkey'), ondelete='SET NULL'),
|
| 72 |
+
sa.PrimaryKeyConstraint('id', name=op.f('posts_pkey'))
|
| 73 |
+
)
|
| 74 |
+
# ### end Alembic commands ###
|
alembic/versions/a34f13019598_drop_platform_model.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""drop platform model
|
| 2 |
+
|
| 3 |
+
Revision ID: a34f13019598
|
| 4 |
+
Revises: 3380d93055a7
|
| 5 |
+
Create Date: 2025-12-07 14:22:13.166768
|
| 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 = 'a34f13019598'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = '3380d93055a7'
|
| 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_column('user_devices', 'platform')
|
| 26 |
+
op.drop_column('user_devices', 'device_model')
|
| 27 |
+
# ### end Alembic commands ###
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def downgrade() -> None:
|
| 31 |
+
"""Downgrade schema."""
|
| 32 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 33 |
+
op.add_column('user_devices', sa.Column('device_model', sa.VARCHAR(), autoincrement=False, nullable=False))
|
| 34 |
+
op.add_column('user_devices', sa.Column('platform', sa.VARCHAR(), autoincrement=False, nullable=False))
|
| 35 |
+
# ### end Alembic commands ###
|
src/chatbot/embedding.py
CHANGED
|
@@ -9,11 +9,8 @@ MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX"
|
|
| 9 |
|
| 10 |
class EmbeddingModel:
|
| 11 |
def __init__(self):
|
| 12 |
-
print("Loading tokenizer…")
|
| 13 |
self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 14 |
|
| 15 |
-
print("Downloading ONNX model files…")
|
| 16 |
-
|
| 17 |
self.model_path = hf_hub_download(
|
| 18 |
repo_id=MODEL_ID,
|
| 19 |
filename="onnx/model.onnx"
|
|
@@ -25,7 +22,6 @@ class EmbeddingModel:
|
|
| 25 |
|
| 26 |
model_dir = os.path.dirname(self.model_path)
|
| 27 |
|
| 28 |
-
print("Creating inference session…")
|
| 29 |
self.session = ort.InferenceSession(
|
| 30 |
self.model_path,
|
| 31 |
providers=["CPUExecutionProvider"],
|
|
|
|
| 9 |
|
| 10 |
class EmbeddingModel:
|
| 11 |
def __init__(self):
|
|
|
|
| 12 |
self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 13 |
|
|
|
|
|
|
|
| 14 |
self.model_path = hf_hub_download(
|
| 15 |
repo_id=MODEL_ID,
|
| 16 |
filename="onnx/model.onnx"
|
|
|
|
| 22 |
|
| 23 |
model_dir = os.path.dirname(self.model_path)
|
| 24 |
|
|
|
|
| 25 |
self.session = ort.InferenceSession(
|
| 26 |
self.model_path,
|
| 27 |
providers=["CPUExecutionProvider"],
|
src/chatbot/router.py
CHANGED
|
@@ -2,11 +2,13 @@ import os
|
|
| 2 |
import shutil
|
| 3 |
import tempfile
|
| 4 |
from typing import Optional
|
|
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 7 |
from sqlalchemy import text
|
| 8 |
from sqlmodel.ext.asyncio.session import AsyncSession
|
| 9 |
|
|
|
|
| 10 |
from src.core.database import get_async_session
|
| 11 |
from .schemas import ManualTextRequest
|
| 12 |
from .service import store_manual_text
|
|
@@ -23,7 +25,7 @@ from .service import process_pdf_and_store
|
|
| 23 |
router = APIRouter(prefix="/chatbot", tags=["chatbot"])
|
| 24 |
|
| 25 |
@router.post("/tokenize", response_model=TokenizeResponse)
|
| 26 |
-
async def tokenize_text(payload: TokenizeRequest):
|
| 27 |
try:
|
| 28 |
encoded = embedding_model.tokenizer(
|
| 29 |
payload.text,
|
|
@@ -44,7 +46,7 @@ async def tokenize_text(payload: TokenizeRequest):
|
|
| 44 |
|
| 45 |
@router.post("/semantic-search", response_model=list[SemanticSearchResult])
|
| 46 |
async def semantic_search(
|
| 47 |
-
payload: SemanticSearchRequest, session: AsyncSession = Depends(get_async_session)
|
| 48 |
):
|
| 49 |
|
| 50 |
if len(payload.embedding) == 0:
|
|
|
|
| 2 |
import shutil
|
| 3 |
import tempfile
|
| 4 |
from typing import Optional
|
| 5 |
+
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 8 |
from sqlalchemy import text
|
| 9 |
from sqlmodel.ext.asyncio.session import AsyncSession
|
| 10 |
|
| 11 |
+
from src.auth.utils import get_current_user
|
| 12 |
from src.core.database import get_async_session
|
| 13 |
from .schemas import ManualTextRequest
|
| 14 |
from .service import store_manual_text
|
|
|
|
| 25 |
router = APIRouter(prefix="/chatbot", tags=["chatbot"])
|
| 26 |
|
| 27 |
@router.post("/tokenize", response_model=TokenizeResponse)
|
| 28 |
+
async def tokenize_text(payload: TokenizeRequest,user_id: UUID = Depends(get_current_user)):
|
| 29 |
try:
|
| 30 |
encoded = embedding_model.tokenizer(
|
| 31 |
payload.text,
|
|
|
|
| 46 |
|
| 47 |
@router.post("/semantic-search", response_model=list[SemanticSearchResult])
|
| 48 |
async def semantic_search(
|
| 49 |
+
payload: SemanticSearchRequest, session: AsyncSession = Depends(get_async_session), user_id: UUID = Depends(get_current_user)
|
| 50 |
):
|
| 51 |
|
| 52 |
if len(payload.embedding) == 0:
|
src/core/__init__.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from src.auth import models as auth_models
|
| 2 |
from src.chatbot import models as chatbot_models
|
| 3 |
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
|
|
|
|
| 1 |
from src.auth import models as auth_models
|
| 2 |
from src.chatbot import models as chatbot_models
|
| 3 |
from src.core import models as core_models
|
|
|
|
| 4 |
from src.home import models as home_models
|
| 5 |
from src.profile import models as profile_models
|
| 6 |
from src.wellbeing import models as wellbeing_models
|
src/core/models.py
CHANGED
|
@@ -26,6 +26,11 @@ class Emotion(str, Enum):
|
|
| 26 |
ANXIOUS = "anxious"
|
| 27 |
SAD = "sad"
|
| 28 |
FRUSTRATED = "frustrated"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
class Users(SQLModel, table=True):
|
| 31 |
__tablename__ = "users"
|
|
|
|
| 26 |
ANXIOUS = "anxious"
|
| 27 |
SAD = "sad"
|
| 28 |
FRUSTRATED = "frustrated"
|
| 29 |
+
|
| 30 |
+
class AppVersion(SQLModel, table=True):
|
| 31 |
+
__tablename__ = "app_version"
|
| 32 |
+
version: str = Field(primary_key=True)
|
| 33 |
+
apk_download_link: str
|
| 34 |
|
| 35 |
class Users(SQLModel, table=True):
|
| 36 |
__tablename__ = "users"
|
src/core/router.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
| 3 |
+
from sqlmodel import select
|
| 4 |
+
|
| 5 |
+
from src.core.database import get_async_session
|
| 6 |
+
from src.core.schemas import BaseResponse
|
| 7 |
+
from src.core.models import AppVersion
|
| 8 |
+
from .schemas import AppConfigResponse
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/app", tags=["AppConfig"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/config", response_model=BaseResponse[AppConfigResponse])
|
| 14 |
+
async def get_app_config(
|
| 15 |
+
session: AsyncSession = Depends(get_async_session),
|
| 16 |
+
):
|
| 17 |
+
result = await session.exec(select(AppVersion))
|
| 18 |
+
row = result.first()
|
| 19 |
+
|
| 20 |
+
min_version = row.version if row else "0.0.0"
|
| 21 |
+
apk_url = row.apk_download_link if row else ""
|
| 22 |
+
|
| 23 |
+
return BaseResponse(
|
| 24 |
+
status_code=200,
|
| 25 |
+
data=AppConfigResponse(
|
| 26 |
+
version=min_version,
|
| 27 |
+
apk_download_link=apk_url
|
| 28 |
+
)
|
| 29 |
+
)
|
| 30 |
+
|
src/core/schemas.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from typing import Generic, TypeVar
|
| 2 |
-
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
T = TypeVar("T")
|
|
@@ -8,3 +8,7 @@ T = TypeVar("T")
|
|
| 8 |
class BaseResponse(BaseModel, Generic[T]):
|
| 9 |
status_code: int
|
| 10 |
data: T
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Generic, TypeVar
|
| 2 |
+
from sqlmodel import SQLModel
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
T = TypeVar("T")
|
|
|
|
| 8 |
class BaseResponse(BaseModel, Generic[T]):
|
| 9 |
status_code: int
|
| 10 |
data: T
|
| 11 |
+
|
| 12 |
+
class AppConfigResponse(SQLModel):
|
| 13 |
+
version: str
|
| 14 |
+
apk_download_link: str
|
src/home/router.py
CHANGED
|
@@ -26,7 +26,8 @@ async def fetch_home_data(
|
|
| 26 |
|
| 27 |
@router.post("/emotion", response_model=BaseResponse[EmotionLogResponse])
|
| 28 |
async def create_or_update_emotion(
|
| 29 |
-
data: EmotionLogCreate, session: AsyncSession = Depends(get_async_session)
|
|
|
|
| 30 |
):
|
| 31 |
record = await add_or_update_emotion(data, session)
|
| 32 |
return {
|
|
|
|
| 26 |
|
| 27 |
@router.post("/emotion", response_model=BaseResponse[EmotionLogResponse])
|
| 28 |
async def create_or_update_emotion(
|
| 29 |
+
data: EmotionLogCreate, session: AsyncSession = Depends(get_async_session),
|
| 30 |
+
user_id: str = Depends(get_current_user),
|
| 31 |
):
|
| 32 |
record = await add_or_update_emotion(data, session)
|
| 33 |
return {
|
src/main.py
CHANGED
|
@@ -10,6 +10,7 @@ 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 src.journaling.router import router as journal
|
|
|
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
|
| 15 |
|
|
@@ -23,7 +24,7 @@ async def on_startup():
|
|
| 23 |
|
| 24 |
app.include_router(home_router, prefix="/home", tags=["Home"])
|
| 25 |
|
| 26 |
-
|
| 27 |
|
| 28 |
app.include_router(profile)
|
| 29 |
|
|
|
|
| 10 |
from src.payslip.router import router as payslip_router
|
| 11 |
from src.profile.router import router as profile
|
| 12 |
from src.journaling.router import router as journal
|
| 13 |
+
from src.core.router import router as app_config
|
| 14 |
from fastapi.staticfiles import StaticFiles
|
| 15 |
|
| 16 |
|
|
|
|
| 24 |
|
| 25 |
app.include_router(home_router, prefix="/home", tags=["Home"])
|
| 26 |
|
| 27 |
+
app.include_router(app_config)
|
| 28 |
|
| 29 |
app.include_router(profile)
|
| 30 |
|
src/notifications/schemas.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
|
| 3 |
class RegisterDeviceRequest(BaseModel):
|
| 4 |
-
device_token: str
|
| 5 |
-
platform: str
|
| 6 |
-
device_model: str
|
|
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
|
| 3 |
class RegisterDeviceRequest(BaseModel):
|
| 4 |
+
device_token: str
|
|
|
|
|
|
src/notifications/service.py
CHANGED
|
@@ -18,8 +18,6 @@ async def register_device(
|
|
| 18 |
device = result.scalar_one_or_none()
|
| 19 |
|
| 20 |
if device:
|
| 21 |
-
device.platform = body.platform
|
| 22 |
-
device.device_model = body.device_model
|
| 23 |
device.last_seen = datetime.utcnow()
|
| 24 |
device.updated_at = datetime.utcnow()
|
| 25 |
|
|
@@ -31,8 +29,6 @@ async def register_device(
|
|
| 31 |
new_device = UserDevices(
|
| 32 |
user_id=user_id,
|
| 33 |
device_token=body.device_token,
|
| 34 |
-
platform=body.platform,
|
| 35 |
-
device_model=body.device_model,
|
| 36 |
)
|
| 37 |
|
| 38 |
session.add(new_device)
|
|
@@ -40,9 +36,11 @@ async def register_device(
|
|
| 40 |
await session.refresh(new_device)
|
| 41 |
return new_device
|
| 42 |
|
|
|
|
| 43 |
from sqlalchemy import select
|
| 44 |
from src.profile.models import UserDevices
|
| 45 |
|
|
|
|
| 46 |
async def get_user_device_tokens(session, user_id):
|
| 47 |
stmt = select(UserDevices.device_token).where(UserDevices.user_id == user_id)
|
| 48 |
rows = (await session.execute(stmt)).all()
|
|
|
|
| 18 |
device = result.scalar_one_or_none()
|
| 19 |
|
| 20 |
if device:
|
|
|
|
|
|
|
| 21 |
device.last_seen = datetime.utcnow()
|
| 22 |
device.updated_at = datetime.utcnow()
|
| 23 |
|
|
|
|
| 29 |
new_device = UserDevices(
|
| 30 |
user_id=user_id,
|
| 31 |
device_token=body.device_token,
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
|
| 34 |
session.add(new_device)
|
|
|
|
| 36 |
await session.refresh(new_device)
|
| 37 |
return new_device
|
| 38 |
|
| 39 |
+
|
| 40 |
from sqlalchemy import select
|
| 41 |
from src.profile.models import UserDevices
|
| 42 |
|
| 43 |
+
|
| 44 |
async def get_user_device_tokens(session, user_id):
|
| 45 |
stmt = select(UserDevices.device_token).where(UserDevices.user_id == user_id)
|
| 46 |
rows = (await session.execute(stmt)).all()
|
src/payslip/router.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# src/payslip/router.py
|
| 2 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
from fastapi.responses import HTMLResponse
|
| 4 |
from urllib.parse import urlencode
|
| 5 |
import uuid
|
|
@@ -18,6 +18,8 @@ from src.payslip.googleservice import (
|
|
| 18 |
)
|
| 19 |
from src.payslip.utils import get_current_user_model
|
| 20 |
from src.payslip.models import PayslipRequest, PayslipStatus
|
|
|
|
|
|
|
| 21 |
|
| 22 |
router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
|
| 23 |
|
|
@@ -27,11 +29,6 @@ async def gmail_connect_url(user_id: uuid.UUID):
|
|
| 27 |
"""
|
| 28 |
Returns the Google OAuth URL for the frontend to open in InAppBrowser.
|
| 29 |
"""
|
| 30 |
-
|
| 31 |
-
print("\n🚀 GOOGLE CONNECT URL CALLED")
|
| 32 |
-
print("Using redirect URI:", settings.GOOGLE_REDIRECT_URI)
|
| 33 |
-
print("Client ID:", settings.GOOGLE_CLIENT_ID)
|
| 34 |
-
|
| 35 |
params = {
|
| 36 |
"client_id": settings.GOOGLE_CLIENT_ID,
|
| 37 |
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
|
|
@@ -41,102 +38,65 @@ async def gmail_connect_url(user_id: uuid.UUID):
|
|
| 41 |
"prompt": "consent",
|
| 42 |
"state": str(user_id),
|
| 43 |
}
|
| 44 |
-
|
| 45 |
-
url = f"{settings.AUTH_BASE}?{urlencode(params)}"
|
| 46 |
-
|
| 47 |
-
print("Generated Google URL:", url)
|
| 48 |
-
|
| 49 |
-
return {"auth_url": url}
|
| 50 |
|
| 51 |
|
| 52 |
@router.get("/gmail/callback")
|
| 53 |
async def gmail_callback(
|
| 54 |
-
|
| 55 |
-
code: str,
|
| 56 |
-
state: str,
|
| 57 |
-
session: AsyncSession = Depends(get_async_session)
|
| 58 |
):
|
| 59 |
-
|
| 60 |
from fastapi.responses import RedirectResponse
|
| 61 |
|
| 62 |
-
print("\n🔔 CALLBACK HIT")
|
| 63 |
-
print("➡ URL seen by backend:", request.url)
|
| 64 |
-
print("➡ Headers:", dict(request.headers))
|
| 65 |
-
print("➡ Query params:", request.query_params)
|
| 66 |
-
|
| 67 |
user_id = uuid.UUID(state)
|
| 68 |
-
print("➡ Extracted user_id from state:", user_id)
|
| 69 |
-
|
| 70 |
-
# LOAD USER
|
| 71 |
user = await session.get(Users, user_id)
|
| 72 |
-
print("➡ User found:", user.email_id if user else "None")
|
| 73 |
|
| 74 |
if not user:
|
| 75 |
-
print("❌ User not found")
|
| 76 |
return RedirectResponse(
|
| 77 |
"yuvabe://gmail/callback?success=false&error=user_not_found&message=No such user exists"
|
| 78 |
)
|
| 79 |
|
| 80 |
-
# EXCHANGE CODE
|
| 81 |
try:
|
| 82 |
-
print("➡ Exchanging code for tokens...")
|
| 83 |
token_data = exchange_code_for_tokens(code)
|
| 84 |
-
print("➡ Token Data:", token_data)
|
| 85 |
-
|
| 86 |
google_email = extract_email_from_id_token(token_data["id_token"])
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
except Exception as e:
|
| 90 |
-
print("❌ OAuth Code Exchange Failed:", e)
|
| 91 |
return RedirectResponse(
|
| 92 |
"yuvabe://gmail/callback?success=false&error=invalid_code&message=OAuth code exchange failed"
|
| 93 |
)
|
| 94 |
|
| 95 |
-
#
|
| 96 |
if google_email.lower() != user.email_id.lower():
|
| 97 |
-
print("❌ Email mismatch! Google:", google_email, "| App:", user.email_id)
|
| 98 |
return RedirectResponse(
|
| 99 |
"yuvabe://gmail/callback?success=false&error=email_mismatch&message=Google account does not match registered email"
|
| 100 |
)
|
| 101 |
|
| 102 |
-
# GET REFRESH TOKEN
|
| 103 |
refresh_token = token_data.get("refresh_token")
|
| 104 |
-
print("➡ Refresh Token:", refresh_token)
|
| 105 |
-
|
| 106 |
if not refresh_token:
|
| 107 |
-
print("❌ Google DID NOT send refresh token")
|
| 108 |
return RedirectResponse(
|
| 109 |
"yuvabe://gmail/callback?success=false&error=no_refresh_token&message=No refresh token returned from Google"
|
| 110 |
)
|
| 111 |
|
| 112 |
-
# SAVE TOKEN
|
| 113 |
q = (
|
| 114 |
select(PayslipRequest)
|
| 115 |
.where(PayslipRequest.user_id == user_id)
|
| 116 |
.order_by(PayslipRequest.requested_at.desc())
|
| 117 |
)
|
| 118 |
existing = (await session.execute(q)).scalar_one_or_none()
|
| 119 |
-
print("➡ Existing request:", existing)
|
| 120 |
|
| 121 |
if existing:
|
| 122 |
-
existing.refresh_token = refresh_token
|
| 123 |
session.add(existing)
|
| 124 |
-
print("➡ Updated existing request with token")
|
| 125 |
else:
|
| 126 |
session.add(
|
| 127 |
PayslipRequest(
|
| 128 |
user_id=user_id,
|
| 129 |
-
refresh_token=refresh_token,
|
| 130 |
status=PayslipStatus.PENDING,
|
| 131 |
)
|
| 132 |
)
|
| 133 |
-
print("➡ Created new PayslipRequest row")
|
| 134 |
|
| 135 |
await session.commit()
|
| 136 |
-
print("➡ DB Commit Successful!")
|
| 137 |
|
| 138 |
-
# SUCCESS
|
| 139 |
-
print("🎉 SUCCESS — Gmail Connected!")
|
| 140 |
return RedirectResponse(
|
| 141 |
"yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
|
| 142 |
)
|
|
@@ -148,14 +108,17 @@ async def request_payslip(
|
|
| 148 |
session: AsyncSession = Depends(get_async_session),
|
| 149 |
user: Users = Depends(get_current_user_model),
|
| 150 |
):
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
entry = await process_payslip_request(session, user, payload)
|
| 156 |
-
|
| 157 |
-
print("➡ Payslip Entry Created:", entry)
|
| 158 |
-
|
| 159 |
return {
|
| 160 |
"status": entry.status,
|
| 161 |
"requested_at": entry.requested_at,
|
|
|
|
| 1 |
# src/payslip/router.py
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
from fastapi.responses import HTMLResponse
|
| 4 |
from urllib.parse import urlencode
|
| 5 |
import uuid
|
|
|
|
| 18 |
)
|
| 19 |
from src.payslip.utils import get_current_user_model
|
| 20 |
from src.payslip.models import PayslipRequest, PayslipStatus
|
| 21 |
+
from src.payslip.utils import encrypt_token
|
| 22 |
+
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/payslips", tags=["Payslips & Gmail"])
|
| 25 |
|
|
|
|
| 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,
|
|
|
|
| 38 |
"prompt": "consent",
|
| 39 |
"state": str(user_id),
|
| 40 |
}
|
| 41 |
+
return {"auth_url": f"{settings.AUTH_BASE}?{urlencode(params)}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
@router.get("/gmail/callback")
|
| 45 |
async def gmail_callback(
|
| 46 |
+
code: str, state: str, session: AsyncSession = Depends(get_async_session)
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 81 |
.order_by(PayslipRequest.requested_at.desc())
|
| 82 |
)
|
| 83 |
existing = (await session.execute(q)).scalar_one_or_none()
|
|
|
|
| 84 |
|
| 85 |
if existing:
|
| 86 |
+
existing.refresh_token = encrypt_token(refresh_token)
|
| 87 |
session.add(existing)
|
|
|
|
| 88 |
else:
|
| 89 |
session.add(
|
| 90 |
PayslipRequest(
|
| 91 |
user_id=user_id,
|
| 92 |
+
refresh_token=encrypt_token(refresh_token),
|
| 93 |
status=PayslipStatus.PENDING,
|
| 94 |
)
|
| 95 |
)
|
|
|
|
| 96 |
|
| 97 |
await session.commit()
|
|
|
|
| 98 |
|
| 99 |
+
# --- SUCCESS MESSAGE ---
|
|
|
|
| 100 |
return RedirectResponse(
|
| 101 |
"yuvabe://gmail/callback?success=true&message=gmail_connected_successfully"
|
| 102 |
)
|
|
|
|
| 108 |
session: AsyncSession = Depends(get_async_session),
|
| 109 |
user: Users = Depends(get_current_user_model),
|
| 110 |
):
|
| 111 |
+
"""
|
| 112 |
+
User hits this when pressing "Request Payslip" in the app.
|
| 113 |
+
We:
|
| 114 |
+
- enforce 1 request per day
|
| 115 |
+
- compute period
|
| 116 |
+
- validate join date
|
| 117 |
+
- load refresh_token from payslip_requests table
|
| 118 |
+
- send email
|
| 119 |
+
- update or create row in payslip_requests
|
| 120 |
+
"""
|
| 121 |
entry = await process_payslip_request(session, user, payload)
|
|
|
|
|
|
|
|
|
|
| 122 |
return {
|
| 123 |
"status": entry.status,
|
| 124 |
"requested_at": entry.requested_at,
|
src/payslip/service.py
CHANGED
|
@@ -15,6 +15,9 @@ from src.payslip.googleservice import (
|
|
| 15 |
send_gmail,
|
| 16 |
)
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
async def user_team_name(session: AsyncSession, user_id):
|
| 20 |
"""Return user's team name."""
|
|
@@ -95,7 +98,7 @@ async def process_payslip_request(
|
|
| 95 |
# 4. Get refresh_token from latest payslip row (DB)
|
| 96 |
latest = await get_latest_payslip_row(session, user.id)
|
| 97 |
|
| 98 |
-
refresh_token = latest.refresh_token if latest else None
|
| 99 |
|
| 100 |
if not refresh_token:
|
| 101 |
# No token stored yet
|
|
@@ -134,7 +137,7 @@ async def process_payslip_request(
|
|
| 134 |
latest.status = PayslipStatus.SENT
|
| 135 |
latest.requested_at = now
|
| 136 |
latest.error_message = None
|
| 137 |
-
latest.refresh_token = refresh_token # keep token
|
| 138 |
session.add(latest)
|
| 139 |
await session.commit()
|
| 140 |
await session.refresh(latest)
|
|
|
|
| 15 |
send_gmail,
|
| 16 |
)
|
| 17 |
|
| 18 |
+
from src.payslip.utils import decrypt_token
|
| 19 |
+
from src.payslip.utils import encrypt_token
|
| 20 |
+
|
| 21 |
|
| 22 |
async def user_team_name(session: AsyncSession, user_id):
|
| 23 |
"""Return user's team name."""
|
|
|
|
| 98 |
# 4. Get refresh_token from latest payslip row (DB)
|
| 99 |
latest = await get_latest_payslip_row(session, user.id)
|
| 100 |
|
| 101 |
+
refresh_token = decrypt_token(latest.refresh_token) if latest else None
|
| 102 |
|
| 103 |
if not refresh_token:
|
| 104 |
# No token stored yet
|
|
|
|
| 137 |
latest.status = PayslipStatus.SENT
|
| 138 |
latest.requested_at = now
|
| 139 |
latest.error_message = None
|
| 140 |
+
latest.refresh_token = encrypt_token(refresh_token) # keep token
|
| 141 |
session.add(latest)
|
| 142 |
await session.commit()
|
| 143 |
await session.refresh(latest)
|
src/payslip/utils.py
CHANGED
|
@@ -13,6 +13,23 @@ from src.core.database import get_async_session
|
|
| 13 |
from src.core.models import Users
|
| 14 |
from src.core.config import settings
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
bearer_scheme = HTTPBearer()
|
| 17 |
|
| 18 |
SECRET_KEY = settings.SECRET_KEY
|
|
|
|
| 13 |
from src.core.models import Users
|
| 14 |
from src.core.config import settings
|
| 15 |
|
| 16 |
+
|
| 17 |
+
from cryptography.fernet import Fernet
|
| 18 |
+
from src.core.config import settings
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
fernet = Fernet(settings.FERNET_KEY.encode())
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def encrypt_token(token: str) -> str:
|
| 25 |
+
"""Encrypts a refresh token before saving to DB."""
|
| 26 |
+
return fernet.encrypt(token.encode()).decode()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def decrypt_token(token: str) -> str:
|
| 30 |
+
"""Decrypts a stored refresh token when needed."""
|
| 31 |
+
return fernet.decrypt(token.encode()).decode()
|
| 32 |
+
|
| 33 |
bearer_scheme = HTTPBearer()
|
| 34 |
|
| 35 |
SECRET_KEY = settings.SECRET_KEY
|
src/profile/models.py
CHANGED
|
@@ -63,6 +63,4 @@ class UserDevices(SQLModel, table=True):
|
|
| 63 |
)
|
| 64 |
device_token: str
|
| 65 |
last_seen: datetime = Field(default_factory=datetime.now)
|
| 66 |
-
device_model: str
|
| 67 |
-
platform: str
|
| 68 |
updated_at: datetime = Field(default_factory=datetime.now)
|
|
|
|
| 63 |
)
|
| 64 |
device_token: str
|
| 65 |
last_seen: datetime = Field(default_factory=datetime.now)
|
|
|
|
|
|
|
| 66 |
updated_at: datetime = Field(default_factory=datetime.now)
|
src/profile/utils.py
CHANGED
|
@@ -12,17 +12,27 @@ 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from src.core.config import settings # for FCM key if needed
|
| 17 |
import httpx
|
| 18 |
-
import math
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
@@ -34,41 +44,51 @@ async def find_mentor_and_lead(session: AsyncSession, user_id) -> Tuple[Optional
|
|
| 34 |
return None, None
|
| 35 |
|
| 36 |
# 2) find Mentor role id
|
| 37 |
-
mentor_role = (
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
mentor_user = None
|
| 41 |
lead_user = None
|
| 42 |
|
| 43 |
if mentor_role:
|
| 44 |
-
mentor_user = (
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
if lead_role:
|
| 52 |
-
lead_user = (
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 72 |
if not tokens:
|
| 73 |
return
|
| 74 |
|
|
@@ -97,8 +117,6 @@ async def send_push_to_tokens(tokens: list[str], title: str, body: str, data: di
|
|
| 97 |
print("FCM send failed:", r.status_code, r.text)
|
| 98 |
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
SMTP_HOST = settings.EMAIL_SERVER
|
| 103 |
SMTP_PORT = settings.EMAIL_PORT
|
| 104 |
SMTP_USER = settings.EMAIL_USERNAME
|
|
|
|
| 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 (
|
| 16 |
+
UserTeamsRole,
|
| 17 |
+
Roles,
|
| 18 |
+
Users,
|
| 19 |
+
Teams,
|
| 20 |
+
) # adjust import path if differs
|
| 21 |
from src.core.config import settings # for FCM key if needed
|
| 22 |
import httpx
|
|
|
|
| 23 |
|
| 24 |
+
|
| 25 |
+
def calculate_days(
|
| 26 |
+
from_date: date, to_date: date, include_weekends: bool = True
|
| 27 |
+
) -> int:
|
| 28 |
"""Calculate inclusive days. If you want to exclude weekends, add logic."""
|
| 29 |
delta = (to_date - from_date).days + 1
|
| 30 |
return max(0, delta)
|
| 31 |
|
| 32 |
+
|
| 33 |
+
async def find_mentor_and_lead(
|
| 34 |
+
session: AsyncSession, user_id
|
| 35 |
+
) -> Tuple[Optional[dict], Optional[dict]]:
|
| 36 |
"""
|
| 37 |
Return (mentor_user, lead_user) as dicts or None.
|
| 38 |
Uses your existing UserTeamsRole and Roles tables to find role members in same team.
|
|
|
|
| 44 |
return None, None
|
| 45 |
|
| 46 |
# 2) find Mentor role id
|
| 47 |
+
mentor_role = (
|
| 48 |
+
await session.exec(select(Roles).where(Roles.name == "Mentor"))
|
| 49 |
+
).first()
|
| 50 |
+
lead_role = (
|
| 51 |
+
await session.exec(select(Roles).where(Roles.name == "Team Lead"))
|
| 52 |
+
).first()
|
| 53 |
|
| 54 |
mentor_user = None
|
| 55 |
lead_user = None
|
| 56 |
|
| 57 |
if mentor_role:
|
| 58 |
+
mentor_user = (
|
| 59 |
+
await session.exec(
|
| 60 |
+
select(Users)
|
| 61 |
+
.join(UserTeamsRole)
|
| 62 |
+
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 63 |
+
.where(UserTeamsRole.role_id == mentor_role.id)
|
| 64 |
+
)
|
| 65 |
+
).first()
|
| 66 |
|
| 67 |
if lead_role:
|
| 68 |
+
lead_user = (
|
| 69 |
+
await session.exec(
|
| 70 |
+
select(Users)
|
| 71 |
+
.join(UserTeamsRole)
|
| 72 |
+
.where(UserTeamsRole.team_id == user_team.team_id)
|
| 73 |
+
.where(UserTeamsRole.role_id == lead_role.id)
|
| 74 |
+
)
|
| 75 |
+
).first()
|
| 76 |
|
| 77 |
return mentor_user, lead_user
|
| 78 |
|
| 79 |
|
|
|
|
| 80 |
async def get_tokens_for_user(session: AsyncSession, user_id) -> list[str]:
|
| 81 |
user = await session.get(Users, user_id)
|
| 82 |
if not user:
|
| 83 |
return []
|
| 84 |
return user.device_tokens or []
|
| 85 |
|
| 86 |
+
|
| 87 |
# Simple FCM send using legacy HTTP API (server key).
|
| 88 |
# In production prefer FCM HTTP v1 (OAuth) or firebase-admin SDK.
|
| 89 |
+
async def send_push_to_tokens(
|
| 90 |
+
tokens: list[str], title: str, body: str, data: dict = None
|
| 91 |
+
):
|
| 92 |
if not tokens:
|
| 93 |
return
|
| 94 |
|
|
|
|
| 117 |
print("FCM send failed:", r.status_code, r.text)
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
| 120 |
SMTP_HOST = settings.EMAIL_SERVER
|
| 121 |
SMTP_PORT = settings.EMAIL_PORT
|
| 122 |
SMTP_USER = settings.EMAIL_USERNAME
|