Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions backend/script/reset_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import asyncio
import json
import re
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path

import src.modules # Ensure all modules are imported so their entities are registered # noqa: F401
Expand All @@ -29,6 +29,8 @@ def parse_date(date_str: str | None) -> datetime | None:
if not date_str or date_str == "null":
return None

now = datetime.now(timezone.utc)

if date_str.startswith("NOW"):
match = re.match(r"NOW([+-])(\d+)([hdwmy])", date_str)
if match:
Expand All @@ -38,19 +40,23 @@ def parse_date(date_str: str | None) -> datetime | None:
amount = -amount

if unit == "h":
return datetime.now() + timedelta(hours=amount)
return now + timedelta(hours=amount)
elif unit == "d":
return datetime.now() + timedelta(days=amount)
return now + timedelta(days=amount)
elif unit == "w":
return datetime.now() + timedelta(weeks=amount)
return now + timedelta(weeks=amount)
elif unit == "m":
return datetime.now() + timedelta(days=amount * 30)
return now + timedelta(days=amount * 30)
elif unit == "y":
return datetime.now() + timedelta(days=amount * 365)
return now + timedelta(days=amount * 365)

return datetime.now()
return now

return datetime.fromisoformat(date_str)
# Parse ISO format string and ensure it's timezone-aware
dt = datetime.fromisoformat(date_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt


async def reset_dev():
Expand Down
6 changes: 3 additions & 3 deletions backend/src/core/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ async def __call__(self, request: Request):
def mock_authenticate(role: AccountRole) -> Account | None:
"""Mock authentication function. Replace with real authentication logic."""
role_to_id = {
AccountRole.STUDENT: 1,
AccountRole.ADMIN: 2,
AccountRole.STAFF: 3,
AccountRole.ADMIN: 1,
AccountRole.STAFF: 2,
AccountRole.STUDENT: 3,
}
role_to_pid = {
AccountRole.STUDENT: "111111111",
Expand Down
12 changes: 9 additions & 3 deletions backend/src/modules/location/location_entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import Self

from sqlalchemy import DECIMAL, DateTime, Index, Integer, String
Expand All @@ -16,7 +16,9 @@ class LocationEntity(EntityBase):
# OCSL Data
warning_count: Mapped[int] = mapped_column(Integer, default=0)
citation_count: Mapped[int] = mapped_column(Integer, default=0)
hold_expiration: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
hold_expiration: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

# Google Maps Data
google_place_id: Mapped[str] = mapped_column(
Expand All @@ -41,6 +43,10 @@ class LocationEntity(EntityBase):
__table_args__ = (Index("idx_lat_lng", "latitude", "longitude"),)

def to_model(self) -> Location:
hold_exp = self.hold_expiration
if hold_exp is not None and hold_exp.tzinfo is None:
hold_exp = hold_exp.replace(tzinfo=timezone.utc)

return Location(
id=self.id,
google_place_id=self.google_place_id,
Expand All @@ -57,7 +63,7 @@ def to_model(self) -> Location:
zip_code=self.zip_code,
warning_count=self.warning_count,
citation_count=self.citation_count,
hold_expiration=self.hold_expiration,
hold_expiration=hold_exp,
)

@classmethod
Expand Down
9 changes: 4 additions & 5 deletions backend/src/modules/location/location_model.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from datetime import datetime
from typing import Self

from pydantic import BaseModel
from pydantic import AwareDatetime, BaseModel
from src.core.models import PaginatedResponse


Expand Down Expand Up @@ -35,15 +34,15 @@ class AddressData(BaseModel):
class LocationData(AddressData):
warning_count: int = 0
citation_count: int = 0
hold_expiration: datetime | None = None
hold_expiration: AwareDatetime | None = None

@classmethod
def from_address(
cls,
address: AddressData,
warning_count: int = 0,
citation_count: int = 0,
hold_expiration: datetime | None = None,
hold_expiration: AwareDatetime | None = None,
) -> Self:
return cls(
google_place_id=address.google_place_id,
Expand Down Expand Up @@ -75,4 +74,4 @@ class LocationCreate(BaseModel):
google_place_id: str
warning_count: int = 0
citation_count: int = 0
hold_expiration: datetime | None = None
hold_expiration: AwareDatetime | None = None
13 changes: 10 additions & 3 deletions backend/src/modules/party/party_entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Self

from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, select
Expand All @@ -19,7 +19,9 @@ class PartyEntity(EntityBase):
__tablename__ = "parties"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
party_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
party_datetime: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
location_id: Mapped[int] = mapped_column(
Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
Expand Down Expand Up @@ -58,9 +60,14 @@ def from_model(cls, data: PartyData) -> Self:

def to_model(self) -> Party:
"""Convert entity to model. Requires relationships to be eagerly loaded."""
# Ensure party_datetime is timezone-aware
party_dt = self.party_datetime
if party_dt.tzinfo is None:
party_dt = party_dt.replace(tzinfo=timezone.utc)

return Party(
id=self.id,
party_datetime=self.party_datetime,
party_datetime=party_dt,
location=self.location.to_model(),
contact_one=self.contact_one.to_dto(),
contact_two=Contact(
Expand Down
11 changes: 5 additions & 6 deletions backend/src/modules/party/party_model.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from datetime import datetime
from typing import Annotated, Literal, Union

from pydantic import BaseModel, EmailStr, Field
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from src.core.models import PaginatedResponse
from src.modules.location.location_model import Location
from src.modules.student.student_model import ContactPreference, Student


class PartyData(BaseModel):
party_datetime: datetime = Field(..., description="Date and time of the party")
party_datetime: AwareDatetime = Field(..., description="Date and time of the party")
location_id: int = Field(
..., description="ID of the location where the party is held"
)
Expand All @@ -34,7 +33,7 @@ class Contact(BaseModel):

class Party(BaseModel):
id: int
party_datetime: datetime = Field(..., description="Date and time of the party")
party_datetime: AwareDatetime = Field(..., description="Date and time of the party")
location: Location = Field(..., description="Location where the party is held")
contact_one: Student = Field(..., description="First contact student")
contact_two: Contact = Field(
Expand All @@ -49,7 +48,7 @@ class StudentCreatePartyDTO(BaseModel):
type: Literal["student"] = Field(
"student", description="Request type discriminator"
)
party_datetime: datetime = Field(..., description="Date and time of the party")
party_datetime: AwareDatetime = Field(..., description="Date and time of the party")
place_id: str = Field(..., description="Google Maps place ID of the location")
contact_two: Contact = Field(
..., description="Contact information for the second contact"
Expand All @@ -61,7 +60,7 @@ class AdminCreatePartyDTO(BaseModel):
Both contacts must be explicitly specified."""

type: Literal["admin"] = Field("admin", description="Request type discriminator")
party_datetime: datetime = Field(..., description="Date and time of the party")
party_datetime: AwareDatetime = Field(..., description="Date and time of the party")
place_id: str = Field(..., description="Google Maps place ID of the location")
contact_one_email: EmailStr = Field(
..., description="Email address of the first contact student"
Expand Down
31 changes: 22 additions & 9 deletions backend/src/modules/party/party_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import csv
import io
import math
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import List

from fastapi import Depends
Expand Down Expand Up @@ -79,11 +79,21 @@ async def _get_party_entity_by_id(self, party_id: int) -> PartyEntity:

def _calculate_business_days_ahead(self, target_date: datetime) -> int:
"""Calculate the number of business days between now and target date."""
current_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
target_date_only = target_date.replace(
# Ensure both datetimes are timezone-aware (use UTC)
current_date = datetime.now(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)

# If target_date is naive, make it UTC-aware; otherwise keep its timezone
if target_date.tzinfo is None:
target_date_only = target_date.replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
)
else:
target_date_only = target_date.replace(
hour=0, minute=0, second=0, microsecond=0
)

business_days = 0
current = current_date

Expand All @@ -105,21 +115,24 @@ async def _validate_party_smart_attendance(self, student_id: int) -> None:
"""Validate that student has completed Party Smart after the most recent August 1st."""
student = await self.student_service.get_student_by_id(student_id)

# Check if last_registered is null
if student.last_registered is None:
raise PartySmartNotCompletedException(student_id)

# Calculate the most recent August 1st
now = datetime.now()
now = datetime.now(timezone.utc)
current_year = now.year

# August 1st of the current year
august_first_this_year = datetime(current_year, 8, 1, 0, 0, 0)
# August 1st of the current year (UTC)
august_first_this_year = datetime(
current_year, 8, 1, 0, 0, 0, tzinfo=timezone.utc
)

# If today is before August 1st, use last year's August 1st
# Otherwise, use this year's August 1st
if now < august_first_this_year:
most_recent_august_first = datetime(current_year - 1, 8, 1, 0, 0, 0)
most_recent_august_first = datetime(
current_year - 1, 8, 1, 0, 0, 0, tzinfo=timezone.utc
)
else:
most_recent_august_first = august_first_this_year

Expand Down Expand Up @@ -414,7 +427,7 @@ async def get_parties_by_student_and_date(
async def get_parties_by_radius(
self, latitude: float, longitude: float
) -> List[Party]:
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
start_time = current_time - timedelta(hours=6)
end_time = current_time + timedelta(hours=12)

Expand Down
16 changes: 13 additions & 3 deletions backend/src/modules/student/student_entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Self

from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
Expand Down Expand Up @@ -37,15 +37,25 @@ def from_model(cls, data: "StudentData", account_id: int) -> Self:
)

def to_model(self) -> "DbStudent":
# Ensure last_registered is timezone-aware if present
last_reg = self.last_registered
if last_reg is not None and last_reg.tzinfo is None:
last_reg = last_reg.replace(tzinfo=timezone.utc)

return DbStudent(
account_id=self.account_id,
contact_preference=self.contact_preference,
last_registered=self.last_registered,
last_registered=last_reg,
phone_number=self.phone_number,
)

def to_dto(self) -> "Student":
"""Convert entity to DTO using the account relationship."""
# Ensure last_registered is timezone-aware if present
last_reg = self.last_registered
if last_reg is not None and last_reg.tzinfo is None:
last_reg = last_reg.replace(tzinfo=timezone.utc)

return Student(
id=self.account_id,
pid=self.account.pid,
Expand All @@ -54,5 +64,5 @@ def to_dto(self) -> "Student":
last_name=self.account.last_name,
phone_number=self.phone_number,
contact_preference=self.contact_preference,
last_registered=self.last_registered,
last_registered=last_reg,
)
9 changes: 4 additions & 5 deletions backend/src/modules/student/student_model.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import enum
from datetime import datetime

from pydantic import BaseModel, EmailStr, Field
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from src.core.models import PaginatedResponse


Expand All @@ -14,7 +13,7 @@ class StudentData(BaseModel):
"""Student data without names (names are stored in Account)."""

contact_preference: ContactPreference
last_registered: datetime | None = None
last_registered: AwareDatetime | None = None
phone_number: str = Field(pattern=r"^\+?1?\d{9,15}$")


Expand All @@ -24,7 +23,7 @@ class StudentDataWithNames(StudentData):
first_name: str = Field(min_length=1)
last_name: str = Field(min_length=1)
contact_preference: ContactPreference
last_registered: datetime | None = None
last_registered: AwareDatetime | None = None
phone_number: str = Field(pattern=r"^\+?1?\d{9,15}$")


Expand Down Expand Up @@ -54,7 +53,7 @@ class Student(BaseModel):
last_name: str
phone_number: str
contact_preference: ContactPreference
last_registered: datetime | None = None
last_registered: AwareDatetime | None = None


class StudentCreate(BaseModel):
Expand Down
Loading