Spaces:
Sleeping
Sleeping
Merge pull request #38 from yuvabe-ai-labs/feat/journaling
Browse files- alembic/versions/217db60578fa_added_journal_table.py +44 -0
- alembic/versions/d9b4df655a55_updated_emotion_tag.py +45 -0
- alembic/versions/fec3872d7eba_add_payslip_table.py +41 -0
- src/auth/utils.py +2 -2
- src/core/__init__.py +3 -1
- src/core/models.py +39 -17
- src/home/schemas.py +7 -9
- src/home/service.py +1 -0
- src/journaling/__init__.py +0 -0
- src/journaling/models.py +32 -0
- src/journaling/router.py +92 -0
- src/journaling/schemas.py +31 -0
- src/journaling/service.py +91 -0
- src/main.py +6 -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/217db60578fa_added_journal_table.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""added journal table
|
| 2 |
+
|
| 3 |
+
Revision ID: 217db60578fa
|
| 4 |
+
Revises: d9b4df655a55
|
| 5 |
+
Create Date: 2025-12-05 14:19:31.722971
|
| 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 = '217db60578fa'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = 'd9b4df655a55'
|
| 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.create_table('journal_entries',
|
| 26 |
+
sa.Column('id', sa.Uuid(), nullable=False),
|
| 27 |
+
sa.Column('user_id', sa.UUID(), nullable=False),
|
| 28 |
+
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
| 29 |
+
sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
| 30 |
+
sa.Column('journal_date', sa.Date(), nullable=False),
|
| 31 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 32 |
+
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
| 33 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
| 34 |
+
sa.PrimaryKeyConstraint('id'),
|
| 35 |
+
sa.UniqueConstraint('user_id', 'journal_date', name='unique_user_date_journal')
|
| 36 |
+
)
|
| 37 |
+
# ### end Alembic commands ###
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def downgrade() -> None:
|
| 41 |
+
"""Downgrade schema."""
|
| 42 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 43 |
+
op.drop_table('journal_entries')
|
| 44 |
+
# ### end Alembic commands ###
|
alembic/versions/d9b4df655a55_updated_emotion_tag.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""updated emotion tag
|
| 2 |
+
|
| 3 |
+
Revision ID: d9b4df655a55
|
| 4 |
+
Revises: fec3872d7eba
|
| 5 |
+
Create Date: 2025-12-04 14:34:22.373838
|
| 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 = 'd9b4df655a55'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = 'fec3872d7eba'
|
| 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 |
+
emotion_enum = sa.Enum(
|
| 25 |
+
'JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED',
|
| 26 |
+
name='emotion_enum'
|
| 27 |
+
)
|
| 28 |
+
emotion_enum.create(op.get_bind(), checkfirst=True)
|
| 29 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 30 |
+
op.add_column('emotion_logs', sa.Column('morning_emotion', sa.Enum('JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED', name='emotion_enum'), nullable=True))
|
| 31 |
+
op.add_column('emotion_logs', sa.Column('evening_emotion', sa.Enum('JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED', name='emotion_enum'), nullable=True))
|
| 32 |
+
# ### end Alembic commands ###
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def downgrade() -> None:
|
| 36 |
+
"""Downgrade schema."""
|
| 37 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 38 |
+
op.drop_column('emotion_logs', 'evening_emotion')
|
| 39 |
+
op.drop_column('emotion_logs', 'morning_emotion')
|
| 40 |
+
emotion_enum = sa.Enum(
|
| 41 |
+
'JOYFUL', 'HAPPY', 'CALM', 'NEUTRAL', 'ANXIOUS', 'SAD', 'FRUSTRATED',
|
| 42 |
+
name='emotion_enum'
|
| 43 |
+
)
|
| 44 |
+
emotion_enum.drop(op.get_bind(), checkfirst=True)
|
| 45 |
+
# ### end Alembic commands ###
|
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/auth/utils.py
CHANGED
|
@@ -156,7 +156,7 @@ async def verify_verification_token(token: str) -> str:
|
|
| 156 |
bearer_scheme = HTTPBearer()
|
| 157 |
|
| 158 |
|
| 159 |
-
def
|
| 160 |
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
| 161 |
):
|
| 162 |
"""Decode JWT token and extract current user ID"""
|
|
@@ -169,7 +169,7 @@ def get_current_user(
|
|
| 169 |
if user_id is None:
|
| 170 |
raise HTTPException(
|
| 171 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 172 |
-
detail="Invalid token:
|
| 173 |
)
|
| 174 |
return user_id
|
| 175 |
|
|
|
|
| 156 |
bearer_scheme = HTTPBearer()
|
| 157 |
|
| 158 |
|
| 159 |
+
def get_current_user(
|
| 160 |
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
| 161 |
):
|
| 162 |
"""Decode JWT token and extract current user ID"""
|
|
|
|
| 169 |
if user_id is None:
|
| 170 |
raise HTTPException(
|
| 171 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 172 |
+
detail="Invalid token: Missing user id",
|
| 173 |
)
|
| 174 |
return user_id
|
| 175 |
|
src/core/__init__.py
CHANGED
|
@@ -4,4 +4,6 @@ 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
|
| 9 |
+
from src.journaling import models as journaling_models
|
src/core/models.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import List, Optional
|
|
| 9 |
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
from sqlalchemy import CheckConstraint, UniqueConstraint, ForeignKey
|
| 11 |
from sqlmodel import Field, Relationship, SQLModel
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class AssetStatus(str, Enum):
|
|
@@ -17,6 +18,14 @@ class AssetStatus(str, Enum):
|
|
| 17 |
ON_REQUEST = "On Request"
|
| 18 |
IN_SERVICE = "In Service"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
class Users(SQLModel, table=True):
|
| 22 |
__tablename__ = "users"
|
|
@@ -30,9 +39,11 @@ 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")
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
class Teams(SQLModel, table=True):
|
|
@@ -51,31 +62,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)
|
|
@@ -86,18 +102,24 @@ class Assets(SQLModel, table=True):
|
|
| 86 |
|
| 87 |
class EmotionLogs(SQLModel, table=True):
|
| 88 |
__tablename__ = "emotion_logs"
|
| 89 |
-
__table_args__ = (
|
| 90 |
-
|
| 91 |
-
CheckConstraint("morning_emotion BETWEEN 1 AND 7 or morning_emotion IS NULL"),
|
| 92 |
-
CheckConstraint("evening_emotion BETWEEN 1 AND 7 or evening_emotion IS NULL"),
|
| 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 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
log_date: date = Field(default_factory=date.today)
|
|
|
|
|
|
| 9 |
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
from sqlalchemy import CheckConstraint, UniqueConstraint, ForeignKey
|
| 11 |
from sqlmodel import Field, Relationship, SQLModel
|
| 12 |
+
from sqlalchemy import Enum as SQLEnum
|
| 13 |
|
| 14 |
|
| 15 |
class AssetStatus(str, Enum):
|
|
|
|
| 18 |
ON_REQUEST = "On Request"
|
| 19 |
IN_SERVICE = "In Service"
|
| 20 |
|
| 21 |
+
class Emotion(str, Enum):
|
| 22 |
+
JOYFUL = "joyful"
|
| 23 |
+
HAPPY = "happy"
|
| 24 |
+
CALM = "calm"
|
| 25 |
+
NEUTRAL = "neutral"
|
| 26 |
+
ANXIOUS = "anxious"
|
| 27 |
+
SAD = "sad"
|
| 28 |
+
FRUSTRATED = "frustrated"
|
| 29 |
|
| 30 |
class Users(SQLModel, table=True):
|
| 31 |
__tablename__ = "users"
|
|
|
|
| 39 |
dob: Optional[date] = None
|
| 40 |
address: Optional[str] = None
|
| 41 |
profile_picture: Optional[str] = None
|
| 42 |
+
join_date: Optional[str] = None
|
| 43 |
created_at: datetime = Field(default_factory=datetime.now)
|
| 44 |
asset: List["Assets"] = Relationship(back_populates="user")
|
| 45 |
water_logs: List["WaterLogs"] = Relationship(back_populates="user")
|
| 46 |
+
journal_entries: List["JournalEntry"] = Relationship(back_populates="user")
|
| 47 |
|
| 48 |
|
| 49 |
class Teams(SQLModel, table=True):
|
|
|
|
| 62 |
__tablename__ = "user_teams_role"
|
| 63 |
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 64 |
user_id: uuid.UUID = Field(
|
| 65 |
+
sa_column=Column(
|
| 66 |
+
UUID(as_uuid=True),
|
| 67 |
ForeignKey("users.id", ondelete="CASCADE"),
|
| 68 |
+
nullable=False,
|
| 69 |
)
|
| 70 |
)
|
| 71 |
team_id: uuid.UUID = Field(
|
| 72 |
+
sa_column=Column(
|
| 73 |
+
UUID(as_uuid=True),
|
| 74 |
ForeignKey("teams.id", ondelete="CASCADE"),
|
| 75 |
+
nullable=False,
|
| 76 |
)
|
| 77 |
)
|
| 78 |
role_id: uuid.UUID = Field(
|
| 79 |
+
sa_column=Column(
|
| 80 |
+
UUID(as_uuid=True),
|
| 81 |
ForeignKey("roles.id", ondelete="CASCADE"),
|
| 82 |
+
nullable=False,
|
| 83 |
)
|
| 84 |
)
|
| 85 |
|
| 86 |
+
|
| 87 |
class Assets(SQLModel, table=True):
|
| 88 |
__tablename__ = "assets"
|
| 89 |
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 90 |
user_id: uuid.UUID = Field(
|
| 91 |
+
sa_column=Column(
|
| 92 |
+
UUID(as_uuid=True),
|
| 93 |
ForeignKey("users.id", ondelete="CASCADE"),
|
| 94 |
+
nullable=False,
|
| 95 |
)
|
| 96 |
)
|
| 97 |
name: str = Field(nullable=False)
|
|
|
|
| 102 |
|
| 103 |
class EmotionLogs(SQLModel, table=True):
|
| 104 |
__tablename__ = "emotion_logs"
|
| 105 |
+
__table_args__ = (UniqueConstraint("user_id", "log_date"),)
|
| 106 |
+
|
|
|
|
|
|
|
|
|
|
| 107 |
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 108 |
+
|
| 109 |
user_id: uuid.UUID = Field(
|
| 110 |
+
sa_column=Column(
|
| 111 |
+
UUID(as_uuid=True),
|
| 112 |
ForeignKey("users.id", ondelete="CASCADE"),
|
| 113 |
+
nullable=False,
|
| 114 |
)
|
| 115 |
)
|
| 116 |
+
|
| 117 |
+
morning_emotion: Optional[Emotion] = Field(
|
| 118 |
+
sa_column=Column(SQLEnum(Emotion, name="emotion_enum", native_enum=True), nullable=True)
|
| 119 |
+
)
|
| 120 |
+
evening_emotion: Optional[Emotion] = Field(
|
| 121 |
+
sa_column=Column(SQLEnum(Emotion, name="emotion_enum", native_enum=True), nullable=True)
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
log_date: date = Field(default_factory=date.today)
|
| 125 |
+
|
src/home/schemas.py
CHANGED
|
@@ -1,22 +1,19 @@
|
|
| 1 |
-
# pylint: disable=no-name-in-module
|
| 2 |
-
# pylint: disable=no-self-argument
|
| 3 |
from datetime import date
|
| 4 |
-
from typing import List, Optional
|
| 5 |
-
|
| 6 |
from pydantic import BaseModel
|
| 7 |
-
|
| 8 |
|
| 9 |
class EmotionLogCreate(BaseModel):
|
| 10 |
user_id: str
|
| 11 |
-
morning_emotion: Optional[
|
| 12 |
-
evening_emotion: Optional[
|
| 13 |
log_date: date
|
| 14 |
|
| 15 |
|
| 16 |
class EmotionLogResponse(BaseModel):
|
| 17 |
log_date: date
|
| 18 |
-
morning_emotion: Optional[
|
| 19 |
-
evening_emotion: Optional[
|
| 20 |
|
| 21 |
|
| 22 |
class HomeResponseData(BaseModel):
|
|
@@ -25,6 +22,7 @@ class HomeResponseData(BaseModel):
|
|
| 25 |
philosophy_text: str
|
| 26 |
recent_emotions: List[EmotionLogResponse]
|
| 27 |
|
|
|
|
| 28 |
class BroadcastNotificationRequest(BaseModel):
|
| 29 |
title: str
|
| 30 |
body: str
|
|
|
|
|
|
|
|
|
|
| 1 |
from datetime import date
|
| 2 |
+
from typing import List, Optional
|
|
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
+
from src.core.models import Emotion
|
| 5 |
|
| 6 |
class EmotionLogCreate(BaseModel):
|
| 7 |
user_id: str
|
| 8 |
+
morning_emotion: Optional[Emotion] = None
|
| 9 |
+
evening_emotion: Optional[Emotion] = None
|
| 10 |
log_date: date
|
| 11 |
|
| 12 |
|
| 13 |
class EmotionLogResponse(BaseModel):
|
| 14 |
log_date: date
|
| 15 |
+
morning_emotion: Optional[Emotion]
|
| 16 |
+
evening_emotion: Optional[Emotion]
|
| 17 |
|
| 18 |
|
| 19 |
class HomeResponseData(BaseModel):
|
|
|
|
| 22 |
philosophy_text: str
|
| 23 |
recent_emotions: List[EmotionLogResponse]
|
| 24 |
|
| 25 |
+
|
| 26 |
class BroadcastNotificationRequest(BaseModel):
|
| 27 |
title: str
|
| 28 |
body: str
|
src/home/service.py
CHANGED
|
@@ -46,6 +46,7 @@ async def get_home_data(user_id: str, session: AsyncSession) -> HomeResponseData
|
|
| 46 |
async def add_or_update_emotion(
|
| 47 |
data: EmotionLogCreate, session: AsyncSession
|
| 48 |
) -> EmotionLogResponse:
|
|
|
|
| 49 |
user_exists = await session.exec(select(Users).where(Users.id == data.user_id))
|
| 50 |
if not user_exists.first():
|
| 51 |
raise ValueError("User not found. Cannot add emotion log.")
|
|
|
|
| 46 |
async def add_or_update_emotion(
|
| 47 |
data: EmotionLogCreate, session: AsyncSession
|
| 48 |
) -> EmotionLogResponse:
|
| 49 |
+
|
| 50 |
user_exists = await session.exec(select(Users).where(Users.id == data.user_id))
|
| 51 |
if not user_exists.first():
|
| 52 |
raise ValueError("User not found. Cannot add emotion log.")
|
src/journaling/__init__.py
ADDED
|
File without changes
|
src/journaling/models.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 6 |
+
from sqlalchemy import Column, ForeignKey, UniqueConstraint
|
| 7 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class JournalEntry(SQLModel, table=True):
|
| 11 |
+
__tablename__ = "journal_entries"
|
| 12 |
+
__table_args__ = (
|
| 13 |
+
UniqueConstraint("user_id", "journal_date", name="unique_user_date_journal"),
|
| 14 |
+
)
|
| 15 |
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 16 |
+
user_id: uuid.UUID = Field(
|
| 17 |
+
sa_column=Column(
|
| 18 |
+
UUID(as_uuid=True),
|
| 19 |
+
ForeignKey("users.id", ondelete="CASCADE"),
|
| 20 |
+
nullable=False,
|
| 21 |
+
)
|
| 22 |
+
)
|
| 23 |
+
title: str = Field(nullable=False)
|
| 24 |
+
content: str = Field(nullable=False)
|
| 25 |
+
journal_date: date = Field(nullable=False)
|
| 26 |
+
created_at: datetime = Field(default_factory=datetime.now)
|
| 27 |
+
updated_at: datetime = Field(
|
| 28 |
+
default_factory=datetime.now,
|
| 29 |
+
sa_column_kwargs={"onupdate": datetime.now},
|
| 30 |
+
nullable=False,
|
| 31 |
+
)
|
| 32 |
+
user: Optional["Users"] = Relationship(back_populates="journal_entries")
|
src/journaling/router.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
| 4 |
+
|
| 5 |
+
from src.core.database import get_async_session
|
| 6 |
+
from src.core.schemas import BaseResponse
|
| 7 |
+
from src.auth.utils import get_current_user
|
| 8 |
+
|
| 9 |
+
from src.journaling.schemas import (
|
| 10 |
+
JournalCreate,
|
| 11 |
+
JournalUpdate,
|
| 12 |
+
JournalResponse,
|
| 13 |
+
)
|
| 14 |
+
from src.journaling.service import (
|
| 15 |
+
create_or_update_journal,
|
| 16 |
+
get_all_journals,
|
| 17 |
+
get_journal,
|
| 18 |
+
update_journal,
|
| 19 |
+
delete_journal,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/journal", tags=["Journal"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/", response_model=BaseResponse[JournalResponse], status_code=200)
|
| 26 |
+
async def create_or_update(
|
| 27 |
+
data: JournalCreate,
|
| 28 |
+
user_id: uuid.UUID = Depends(get_current_user),
|
| 29 |
+
session: AsyncSession = Depends(get_async_session),
|
| 30 |
+
):
|
| 31 |
+
data.user_id = user_id
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
record = await create_or_update_journal(data, session)
|
| 35 |
+
return BaseResponse(status_code=200, data=record)
|
| 36 |
+
except ValueError as e:
|
| 37 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/", response_model=BaseResponse[list[JournalResponse]], status_code=200)
|
| 41 |
+
async def list_user_journals(
|
| 42 |
+
user_id: uuid.UUID = Depends(get_current_user),
|
| 43 |
+
session: AsyncSession = Depends(get_async_session),
|
| 44 |
+
):
|
| 45 |
+
records = await get_all_journals(user_id, session)
|
| 46 |
+
return BaseResponse(status_code=200, data=records)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/entry/{journal_id}", response_model=BaseResponse[JournalResponse], status_code=200)
|
| 50 |
+
async def fetch_single(
|
| 51 |
+
journal_id: uuid.UUID,
|
| 52 |
+
user_id: uuid.UUID = Depends(get_current_user),
|
| 53 |
+
session: AsyncSession = Depends(get_async_session),
|
| 54 |
+
):
|
| 55 |
+
try:
|
| 56 |
+
record = await get_journal(journal_id, user_id, session)
|
| 57 |
+
return BaseResponse(status_code=200, data=record)
|
| 58 |
+
except ValueError as e:
|
| 59 |
+
if str(e) == "Not authorized":
|
| 60 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 61 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.put("/entry/{journal_id}", response_model=BaseResponse[JournalResponse], status_code=200)
|
| 65 |
+
async def update_entry(
|
| 66 |
+
journal_id: uuid.UUID,
|
| 67 |
+
data: JournalUpdate,
|
| 68 |
+
user_id: uuid.UUID = Depends(get_current_user),
|
| 69 |
+
session: AsyncSession = Depends(get_async_session),
|
| 70 |
+
):
|
| 71 |
+
try:
|
| 72 |
+
record = await update_journal(journal_id, data, user_id, session)
|
| 73 |
+
return BaseResponse(status_code=200, data=record)
|
| 74 |
+
except ValueError as e:
|
| 75 |
+
if str(e) == "Not authorized":
|
| 76 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 77 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.delete("/entry/{journal_id}", response_model=BaseResponse[str], status_code=200)
|
| 81 |
+
async def delete_entry(
|
| 82 |
+
journal_id: uuid.UUID,
|
| 83 |
+
user_id: uuid.UUID = Depends(get_current_user),
|
| 84 |
+
session: AsyncSession = Depends(get_async_session),
|
| 85 |
+
):
|
| 86 |
+
try:
|
| 87 |
+
await delete_journal(journal_id, user_id, session)
|
| 88 |
+
return BaseResponse(status_code=200, data="Deleted")
|
| 89 |
+
except ValueError as e:
|
| 90 |
+
if str(e) == "Not authorized":
|
| 91 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 92 |
+
raise HTTPException(status_code=404, detail=str(e))
|
src/journaling/schemas.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from sqlmodel import SQLModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class JournalBase(SQLModel):
|
| 9 |
+
title: str
|
| 10 |
+
content: str
|
| 11 |
+
journal_date: date
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class JournalCreate(JournalBase):
|
| 15 |
+
user_id: Optional[uuid.UUID] = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class JournalUpdate(SQLModel):
|
| 19 |
+
title: Optional[str] = None
|
| 20 |
+
content: Optional[str] = None
|
| 21 |
+
journal_date: Optional[date] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class JournalResponse(SQLModel):
|
| 25 |
+
id: uuid.UUID
|
| 26 |
+
user_id: uuid.UUID
|
| 27 |
+
title: str
|
| 28 |
+
content: str
|
| 29 |
+
journal_date: date
|
| 30 |
+
created_at: datetime
|
| 31 |
+
updated_at: datetime
|
src/journaling/service.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
import uuid
|
| 3 |
+
from sqlmodel import select
|
| 4 |
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
| 5 |
+
|
| 6 |
+
from src.core.models import Users
|
| 7 |
+
from src.journaling.models import JournalEntry
|
| 8 |
+
from src.journaling.schemas import JournalCreate, JournalUpdate
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def create_or_update_journal(data: JournalCreate, session: AsyncSession):
|
| 12 |
+
user = await session.exec(select(Users).where(Users.id == data.user_id))
|
| 13 |
+
if not user.first():
|
| 14 |
+
raise ValueError("User not found")
|
| 15 |
+
|
| 16 |
+
query = select(JournalEntry).where(
|
| 17 |
+
JournalEntry.user_id == data.user_id,
|
| 18 |
+
JournalEntry.journal_date == data.journal_date
|
| 19 |
+
)
|
| 20 |
+
existing = (await session.exec(query)).first()
|
| 21 |
+
|
| 22 |
+
if existing:
|
| 23 |
+
existing.title = data.title
|
| 24 |
+
existing.content = data.content
|
| 25 |
+
existing.updated_at = datetime.utcnow()
|
| 26 |
+
record = existing
|
| 27 |
+
else:
|
| 28 |
+
record = JournalEntry(
|
| 29 |
+
user_id=data.user_id,
|
| 30 |
+
title=data.title,
|
| 31 |
+
content=data.content,
|
| 32 |
+
journal_date=data.journal_date,
|
| 33 |
+
)
|
| 34 |
+
session.add(record)
|
| 35 |
+
|
| 36 |
+
await session.commit()
|
| 37 |
+
await session.refresh(record)
|
| 38 |
+
return record
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def get_all_journals(user_id: uuid.UUID, session: AsyncSession):
|
| 42 |
+
stmt = (
|
| 43 |
+
select(JournalEntry)
|
| 44 |
+
.where(JournalEntry.user_id == user_id)
|
| 45 |
+
.order_by(JournalEntry.journal_date.desc())
|
| 46 |
+
)
|
| 47 |
+
result = await session.exec(stmt)
|
| 48 |
+
return result.all()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def get_journal(journal_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession):
|
| 52 |
+
stmt = select(JournalEntry).where(JournalEntry.id == journal_id)
|
| 53 |
+
result = await session.exec(stmt)
|
| 54 |
+
record = result.first()
|
| 55 |
+
|
| 56 |
+
if not record:
|
| 57 |
+
raise ValueError("Journal entry not found")
|
| 58 |
+
if str(record.user_id) != user_id:
|
| 59 |
+
raise ValueError("Not authorized")
|
| 60 |
+
|
| 61 |
+
return record
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def update_journal(
|
| 65 |
+
journal_id: uuid.UUID,
|
| 66 |
+
data: JournalUpdate,
|
| 67 |
+
user_id: uuid.UUID,
|
| 68 |
+
session: AsyncSession,
|
| 69 |
+
):
|
| 70 |
+
record = await get_journal(journal_id, user_id, session)
|
| 71 |
+
|
| 72 |
+
if data.title is not None:
|
| 73 |
+
record.title = data.title
|
| 74 |
+
|
| 75 |
+
if data.content is not None:
|
| 76 |
+
record.content = data.content
|
| 77 |
+
|
| 78 |
+
if data.journal_date is not None:
|
| 79 |
+
record.journal_date = data.journal_date
|
| 80 |
+
|
| 81 |
+
record.updated_at = datetime.utcnow()
|
| 82 |
+
|
| 83 |
+
await session.commit()
|
| 84 |
+
await session.refresh(record)
|
| 85 |
+
return record
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def delete_journal(journal_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession):
|
| 89 |
+
record = await get_journal(journal_id, user_id, session)
|
| 90 |
+
await session.delete(record)
|
| 91 |
+
await session.commit()
|
src/main.py
CHANGED
|
@@ -7,7 +7,9 @@ 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 |
|
| 13 |
|
|
@@ -33,6 +35,10 @@ 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 src.journaling.router import router as journal
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
|
| 15 |
|
|
|
|
| 35 |
|
| 36 |
app.include_router(notifications_router)
|
| 37 |
|
| 38 |
+
app.include_router(payslip_router)
|
| 39 |
+
|
| 40 |
+
app.include_router(journal)
|
| 41 |
+
|
| 42 |
|
| 43 |
@app.get("/")
|
| 44 |
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")
|