diff --git a/api/routers/notifications.py b/api/routers/notifications.py index adb20ede..192f4afb 100644 --- a/api/routers/notifications.py +++ b/api/routers/notifications.py @@ -27,6 +27,7 @@ NotificationResponse, NotificationStatsResponse, ) +from api.timeutils import utcnow router = APIRouter(tags=["notifications"]) @@ -147,8 +148,6 @@ async def mark_notification_read( Sets ``opened_at`` to now (idempotent — does nothing if already set). The mobile Inbox calls this when the user taps a row. """ - from api.timeutils import utcnow - notification = db.query(Notification).filter(Notification.id == notification_id).first() if not notification: raise HTTPException(status_code=404, detail="Notification not found") @@ -282,9 +281,9 @@ async def get_organization_notification_stats( # Verify admin belongs to organization verify_org_member(admin, org_id) - from datetime import datetime, timedelta + from datetime import timedelta - cutoff_date = datetime.utcnow() - timedelta(days=days) + cutoff_date = utcnow() - timedelta(days=days) # Total notifications by status status_counts = ( diff --git a/api/routers/recurring_events.py b/api/routers/recurring_events.py index aff2e7e0..243b3262 100644 --- a/api/routers/recurring_events.py +++ b/api/routers/recurring_events.py @@ -26,6 +26,7 @@ generate_occurrences, validate_series_duration, ) +from api.timeutils import utcnow router = APIRouter(tags=["recurring-events"]) @@ -454,7 +455,7 @@ def update_series_template( if role_requirements is not None: series.role_requirements = role_requirements - series.updated_at = datetime.utcnow() + series.updated_at = utcnow() db.commit() db.refresh(series) diff --git a/api/routers/sms.py b/api/routers/sms.py index 68edf1a0..f51da0db 100644 --- a/api/routers/sms.py +++ b/api/routers/sms.py @@ -23,6 +23,7 @@ send_broadcast_message, send_event_reminder, ) +from api.timeutils import utcnow router = APIRouter(prefix="/api/sms", tags=["sms"]) @@ -166,9 +167,9 @@ def send_verification_code( db=db, person_id=request.person_id, phone_number=request.phone_number ) - from datetime import datetime, timedelta + from datetime import timedelta - expires_at = (datetime.utcnow() + timedelta(minutes=10)).isoformat() + expires_at = (utcnow() + timedelta(minutes=10)).isoformat() return VerificationCodeResponse( message="Verification code sent via SMS", @@ -308,7 +309,6 @@ def get_sms_usage_stats( Returns current month usage with budget tracking. """ - from datetime import datetime from api.models import SmsUsage @@ -318,7 +318,7 @@ def get_sms_usage_stats( raise HTTPException(status_code=403, detail="Access denied: wrong organization") # Get current month usage - current_month = datetime.utcnow().strftime("%Y-%m") + current_month = utcnow().strftime("%Y-%m") usage = ( db.query(SmsUsage) @@ -548,7 +548,6 @@ async def twilio_delivery_status_webhook(request: Request, db: Session = Depends raise HTTPException(status_code=422, detail="Missing required fields") # Update message status in database - from datetime import datetime from api.models import SmsMessage @@ -560,9 +559,9 @@ async def twilio_delivery_status_webhook(request: Request, db: Session = Depends sms_message.status = message_status if message_status == "delivered": - sms_message.delivered_at = datetime.utcnow() + sms_message.delivered_at = utcnow() elif message_status in ["failed", "undelivered"]: - sms_message.failed_at = datetime.utcnow() + sms_message.failed_at = utcnow() sms_message.error_message = form_data.get("ErrorMessage", "Unknown error") db.commit() diff --git a/api/routers/solutions.py b/api/routers/solutions.py index f5849194..d8b00022 100644 --- a/api/routers/solutions.py +++ b/api/routers/solutions.py @@ -1,6 +1,5 @@ """Solutions router - view and export generated solutions.""" -from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy.orm import Session @@ -167,7 +166,7 @@ def create_manual_solution( soft_score=solution_data.get("soft_score", 0.0), health_score=solution_data.get("health_score", 0.0), metrics=solution_data.get("metrics", {}), - created_at=datetime.utcnow(), + created_at=utcnow(), ) db.add(new_solution) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 22fc1943..e7fdb9ab 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -25,6 +25,7 @@ SubscriptionEvent, UsageMetrics, ) +from api.timeutils import utcnow from api.utils.stripe_client import StripeClient @@ -213,7 +214,7 @@ def update_volunteer_count(self, org_id: str) -> UsageMetrics | None: metric.current_value = volunteer_count metric.plan_limit = plan_limit metric.percentage_used = percentage_used - metric.last_updated = datetime.utcnow() + metric.last_updated = utcnow() else: metric = UsageMetrics( org_id=org_id, @@ -524,13 +525,13 @@ def start_trial( previous_plan = subscription.plan_tier # Calculate trial end date - trial_end = datetime.utcnow() + timedelta(days=trial_days) + trial_end = utcnow() + timedelta(days=trial_days) # Update subscription to trial status subscription.plan_tier = plan_tier subscription.status = "trialing" subscription.trial_end_date = trial_end - subscription.current_period_start = datetime.utcnow() + subscription.current_period_start = utcnow() subscription.current_period_end = trial_end self.db.commit() @@ -594,7 +595,7 @@ def auto_downgrade_expired_trials(self) -> dict[str, Any]: # Called daily by Celery task """ try: - now = datetime.utcnow() + now = utcnow() # Find all expired trials expired_trials = ( @@ -893,7 +894,7 @@ def _calculate_prorated_amount(self, subscription: Subscription, new_billing_cyc - Positive: Charge (monthly → annual) - Negative: Credit (annual → monthly) """ - now = datetime.utcnow() + now = utcnow() current_cycle = subscription.billing_cycle plan_tier = subscription.plan_tier @@ -1003,7 +1004,7 @@ def downgrade_subscription( } # Calculate effective date (end of current period) - effective_date = subscription.current_period_end or datetime.utcnow() + effective_date = subscription.current_period_end or utcnow() # Calculate credit for unused time credit_amount = self._calculate_downgrade_credit( @@ -1016,7 +1017,7 @@ def downgrade_subscription( "effective_date": effective_date.isoformat(), "credit_amount_cents": credit_amount, "reason": reason, - "scheduled_at": datetime.utcnow().isoformat(), + "scheduled_at": utcnow().isoformat(), } self.db.commit() @@ -1062,7 +1063,7 @@ def _calculate_downgrade_credit(self, subscription: Subscription, new_plan_tier: Returns: int: Credit amount in cents """ - now = datetime.utcnow() + now = utcnow() current_plan = subscription.plan_tier billing_cycle = subscription.billing_cycle @@ -1119,7 +1120,7 @@ def apply_pending_downgrades(self) -> dict[str, Any]: } """ try: - now = datetime.utcnow() + now = utcnow() applied_downgrades = [] # Find all subscriptions with pending downgrades @@ -1395,7 +1396,7 @@ def reactivate_subscription(self, org_id: str, admin_id: str | None = None) -> d "message": "Subscription was not cancelled or retention period has expired", } - now = datetime.utcnow() + now = utcnow() if now > org.data_retention_until: return { diff --git a/api/services/email_service.py b/api/services/email_service.py index 3a4224b6..e5a8df3a 100644 --- a/api/services/email_service.py +++ b/api/services/email_service.py @@ -401,7 +401,6 @@ def _update_notification_status( sendgrid_message_id: Optional SendGrid message ID error_message: Optional error message """ - from datetime import datetime notification.status = status @@ -412,7 +411,7 @@ def _update_notification_status( notification.error_message = error_message if status == "sent": - notification.sent_at = datetime.utcnow() + notification.sent_at = utcnow() db.commit() diff --git a/api/services/notification_service.py b/api/services/notification_service.py index 37b0a94d..9dd98146 100644 --- a/api/services/notification_service.py +++ b/api/services/notification_service.py @@ -8,7 +8,6 @@ import logging import os import secrets -from datetime import datetime from typing import Any from sqlalchemy.orm import Session @@ -24,6 +23,7 @@ Person, ) from api.tasks.notifications import send_email_task +from api.timeutils import utcnow logger = logging.getLogger(__name__) _TRUTHY_VALUES = {"1", "true", "yes", "on"} @@ -141,7 +141,7 @@ def create_assignment_notifications( "assignment_id": assignment.id, "role": assignment.role if hasattr(assignment, "role") else None, }, - created_at=datetime.utcnow(), + created_at=utcnow(), ) db.add(notification) db.flush() # Flush to get notification ID @@ -217,7 +217,7 @@ def create_notification( status=NotificationStatus.PENDING, event_id=event_id, template_data=template_data or {}, - created_at=datetime.utcnow(), + created_at=utcnow(), ) db.add(notification) db.flush() diff --git a/api/services/sms_service.py b/api/services/sms_service.py index 9cebdc9c..bd1d3839 100644 --- a/api/services/sms_service.py +++ b/api/services/sms_service.py @@ -11,7 +11,7 @@ import os import random import re -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any from jinja2 import Template @@ -28,6 +28,7 @@ SmsTemplate, SmsVerificationCode, ) +from api.timeutils import utcnow from api.utils.cost_tracker import CostTracker from api.utils.quiet_hours import QuietHours from api.utils.sms_rate_limiter import SmsRateLimiter @@ -130,7 +131,7 @@ def send_sms( twilio_message_sid=twilio_message.sid, cost_cents=cost_cents, is_urgent=is_urgent, - sent_at=datetime.utcnow(), + sent_at=utcnow(), ) db.add(sms_message) @@ -165,7 +166,7 @@ def send_sms( error_message=str(e), cost_cents=0, is_urgent=is_urgent, - failed_at=datetime.utcnow(), + failed_at=utcnow(), ) db.add(sms_message) db.commit() @@ -350,7 +351,7 @@ def generate_verification_code(self, db: Session, person_id: int, phone_number: """ # 1. Generate random 6-digit code code = random.randint(100000, 999999) - expires_at = datetime.utcnow() + timedelta(minutes=10) + expires_at = utcnow() + timedelta(minutes=10) # 2. Delete any existing verification codes for this person db.query(SmsVerificationCode).filter(SmsVerificationCode.person_id == person_id).delete() @@ -413,7 +414,7 @@ def verify_code(self, db: Session, person_id: int, code: int) -> dict[str, Any]: raise ValueError("No verification code found. Please request a new code.") # 2. Check expiration (10 minutes) - if datetime.utcnow() > verification.expires_at: + if utcnow() > verification.expires_at: db.delete(verification) db.commit() raise ValueError("Verification code expired. Please request a new code.") @@ -443,7 +444,7 @@ def verify_code(self, db: Session, person_id: int, code: int) -> dict[str, Any]: phone_number=verification.phone_number, verified=True, notification_types=["assignment", "reminder", "change", "cancellation"], - opt_in_date=datetime.utcnow(), + opt_in_date=utcnow(), language="en", timezone="UTC", ) @@ -451,7 +452,7 @@ def verify_code(self, db: Session, person_id: int, code: int) -> dict[str, Any]: else: sms_pref.verified = True sms_pref.phone_number = verification.phone_number - sms_pref.opt_in_date = datetime.utcnow() + sms_pref.opt_in_date = utcnow() # 6. Delete verification code record db.delete(verification) @@ -545,7 +546,7 @@ def process_incoming_reply( event_id=None, action_taken=action, twilio_message_sid=twilio_message_sid, - processed_at=datetime.utcnow(), + processed_at=utcnow(), ) db.add(sms_reply) db.commit() @@ -578,7 +579,7 @@ def process_yes_reply( .join(Event) .filter( Assignment.person_id == person_id, - Event.datetime >= datetime.utcnow(), + Event.datetime >= utcnow(), ) .order_by(Event.datetime.asc()) .first() @@ -624,7 +625,7 @@ def process_no_reply(self, db: Session, person_id: int, original_message_id: int .join(Event) .filter( Assignment.person_id == person_id, - Event.datetime >= datetime.utcnow(), + Event.datetime >= utcnow(), ) .order_by(Event.datetime.asc()) .first() @@ -672,7 +673,7 @@ def process_stop_reply(self, db: Session, person_id: int) -> str: return "You are not subscribed to SMS notifications." # 2. Update opt_out_date (TCPA compliance) - sms_pref.opt_out_date = datetime.utcnow() + sms_pref.opt_out_date = utcnow() db.commit() # 3. Return opt-out confirmation @@ -704,7 +705,7 @@ def process_start_reply(self, db: Session, person_id: int) -> str: # 3. Clear opt_out_date to re-enable sms_pref.opt_out_date = None - sms_pref.opt_in_date = datetime.utcnow() # Update opt-in date + sms_pref.opt_in_date = utcnow() # Update opt-in date db.commit() # 4. Return re-enablement confirmation diff --git a/api/services/usage_service.py b/api/services/usage_service.py index 4d922048..f35f00a6 100644 --- a/api/services/usage_service.py +++ b/api/services/usage_service.py @@ -17,13 +17,13 @@ """ import os -from datetime import datetime from typing import Any from sqlalchemy.orm import Session, joinedload from api.logging_config import logger from api.models import Organization, Subscription, UsageMetrics +from api.timeutils import utcnow class UsageService: @@ -352,7 +352,7 @@ def _update_volunteer_count(self, org_id: str) -> UsageMetrics | None: metric.current_value = volunteer_count metric.plan_limit = plan_limit metric.percentage_used = percentage_used - metric.last_updated = datetime.utcnow() + metric.last_updated = utcnow() else: metric = UsageMetrics( org_id=org_id, diff --git a/api/tasks/billing_tasks.py b/api/tasks/billing_tasks.py index 4c8fbf77..d7891a3e 100644 --- a/api/tasks/billing_tasks.py +++ b/api/tasks/billing_tasks.py @@ -21,6 +21,7 @@ # Use the same Celery app instance from api.tasks.sms_tasks import celery_app +from api.timeutils import utcnow @celery_app.task(bind=True, max_retries=3) @@ -257,11 +258,11 @@ def process_cancelled_subscriptions(self) -> dict[str, Any]: db = SessionLocal() try: - from datetime import datetime, timedelta + from datetime import timedelta from api.models import Organization, Subscription, SubscriptionEvent - now = datetime.utcnow() + now = utcnow() cancelled_orgs = [] # Find all subscriptions marked for cancellation at period end @@ -368,11 +369,9 @@ def mark_organizations_for_deletion(self) -> dict[str, Any]: db = SessionLocal() try: - from datetime import datetime - from api.models import Organization - now = datetime.utcnow() + now = utcnow() marked_orgs = [] # Find organizations past retention period diff --git a/api/tasks/notifications.py b/api/tasks/notifications.py index 4632b181..cc8a2c61 100644 --- a/api/tasks/notifications.py +++ b/api/tasks/notifications.py @@ -6,7 +6,7 @@ """ import logging -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any from sqlalchemy.orm import Session @@ -25,6 +25,7 @@ Person, ) from api.services.email_service import email_service +from api.timeutils import utcnow logger = logging.getLogger(__name__) @@ -209,7 +210,7 @@ def _send_reminder_notification( return None # Calculate hours remaining - now = datetime.utcnow() + now = utcnow() hours_remaining = int((event.start_time - now).total_seconds() / 3600) # Format event datetime @@ -377,7 +378,7 @@ def send_reminder_emails() -> dict[str, Any]: try: # Find events happening in 23-25 hours (24-hour window with 1-hour buffer) - now = datetime.utcnow() + now = utcnow() reminder_start = now + timedelta(hours=23) reminder_end = now + timedelta(hours=25) @@ -437,7 +438,7 @@ def send_reminder_emails() -> dict[str, Any]: "role": assignment.role if hasattr(assignment, "role") else None, "hours_remaining": int((event.start_time - now).total_seconds() / 3600), }, - created_at=datetime.utcnow(), + created_at=utcnow(), ) db.add(notification) db.flush() diff --git a/api/utils/calendar_utils.py b/api/utils/calendar_utils.py index 843e49e1..e9292654 100644 --- a/api/utils/calendar_utils.py +++ b/api/utils/calendar_utils.py @@ -14,6 +14,8 @@ from icalendar import Calendar, vDatetime from icalendar import Event as ICalEvent +from api.timeutils import utcnow + def generate_ics_from_assignments( assignments: list[dict[str, Any]], @@ -45,7 +47,7 @@ def generate_ics_from_assignments( # Required fields event_data = assignment.get("event", {}) event.add("uid", f"rostio-assignment-{assignment.get('id')}@rostio.app") - event.add("dtstamp", datetime.utcnow().replace(tzinfo=ZoneInfo("UTC"))) + event.add("dtstamp", utcnow().replace(tzinfo=ZoneInfo("UTC"))) # Event times start_time = event_data.get("start_time") @@ -133,7 +135,7 @@ def generate_ics_from_events( # Required fields event.add("uid", f"rostio-event-{event_data.get('id')}@rostio.app") - event.add("dtstamp", datetime.utcnow().replace(tzinfo=ZoneInfo("UTC"))) + event.add("dtstamp", utcnow().replace(tzinfo=ZoneInfo("UTC"))) # Event times start_time = event_data.get("start_time") diff --git a/api/utils/cost_tracker.py b/api/utils/cost_tracker.py index 4d7dd1ee..0c089b89 100644 --- a/api/utils/cost_tracker.py +++ b/api/utils/cost_tracker.py @@ -7,12 +7,12 @@ - Auto-pause at budget limit (optional) """ -from datetime import datetime from typing import Any from sqlalchemy.orm import Session from api.models import SmsUsage +from api.timeutils import utcnow class CostTracker: @@ -56,7 +56,7 @@ def track_usage( Dictionary with budget status and alert info """ # Get or create usage record for current month - month_year = datetime.utcnow().strftime("%Y-%m") + month_year = utcnow().strftime("%Y-%m") usage = ( db.query(SmsUsage) @@ -134,7 +134,7 @@ def check_budget(self, db: Session, organization_id: int) -> dict[str, Any]: Returns: Dictionary with budget status """ - month_year = datetime.utcnow().strftime("%Y-%m") + month_year = utcnow().strftime("%Y-%m") usage = ( db.query(SmsUsage) diff --git a/api/utils/sms_rate_limiter.py b/api/utils/sms_rate_limiter.py index f20f3948..9fb97f01 100644 --- a/api/utils/sms_rate_limiter.py +++ b/api/utils/sms_rate_limiter.py @@ -8,10 +8,12 @@ """ import os -from datetime import datetime, timedelta +from datetime import timedelta import redis +from api.timeutils import utcnow + class SmsRateLimiter: """Redis-based rate limiter for SMS messages.""" @@ -114,7 +116,7 @@ def reset_count(self, person_id: int) -> None: def _seconds_until_midnight(self) -> int: """Calculate seconds until midnight for expiration.""" - now = datetime.utcnow() + now = utcnow() midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) delta = midnight - now return int(delta.total_seconds())