stuff changed:

- ui has been made 'kinda better' (after making it worse for a while lol
- ESP rfid readers are now supported [ill upload the code for them in another repo later]
- admin system has been secured a bit better and seems to be working well
This commit is contained in:
2026-05-08 20:46:58 +01:00
parent 1a0b4dc25d
commit d024bf7fa3
32 changed files with 7480 additions and 2740 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags, esp
api_router = APIRouter()
@@ -12,3 +12,4 @@ api_router.include_router(email.router, prefix="/email", tags=["email"])
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
api_router.include_router(events.router, prefix="/events", tags=["events"])
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"])
+8 -7
View File
@@ -6,6 +6,7 @@ from typing import List
import uuid
from ...core.database import get_db
from ...core.datetime import utc_now
from ...core.security import verify_password, get_password_hash, create_access_token
from ...models.models import User, UserRole, PasswordResetToken
from ...schemas import (
@@ -85,7 +86,7 @@ async def login(
)
# Update last login
user.last_login = datetime.utcnow()
user.last_login = utc_now()
db.commit()
# Create access token
@@ -120,7 +121,7 @@ async def login_json(
)
# Update last login
user.last_login = datetime.utcnow()
user.last_login = utc_now()
db.commit()
# Create access token
@@ -149,12 +150,12 @@ async def forgot_password(
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
PasswordResetToken.expires_at > utc_now()
).update({"used": True})
# Generate new reset token
reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour
expires_at = utc_now() + timedelta(hours=1) # Token expires in 1 hour
# Create password reset token
db_token = PasswordResetToken(
@@ -192,7 +193,7 @@ async def reset_password(
reset_token = db.query(PasswordResetToken).filter(
PasswordResetToken.token == request.token,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
PasswordResetToken.expires_at > utc_now()
).first()
if not reset_token:
@@ -212,7 +213,7 @@ async def reset_password(
# Update password
hashed_password = get_password_hash(request.new_password)
user.hashed_password = hashed_password
user.updated_at = datetime.utcnow()
user.updated_at = utc_now()
# Mark token as used
reset_token.used = True
@@ -239,7 +240,7 @@ async def change_password(
# Update password
hashed_password = get_password_hash(request.new_password)
current_user.hashed_password = hashed_password
current_user.updated_at = datetime.utcnow()
current_user.updated_at = utc_now()
db.commit()
+3 -2
View File
@@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user
from ...models.models import User
from typing import Dict, Any, List
from ...core.database import get_db
from ...core.datetime import to_zulu_iso
from sqlalchemy.orm import Session
router = APIRouter()
@@ -95,7 +96,7 @@ async def get_bounce_list(
"email": bounce.email,
"bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(),
"bounce_date": to_zulu_iso(bounce.bounce_date),
"is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id
}
@@ -132,7 +133,7 @@ async def get_bounce_history(
"id": bounce.id,
"bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(),
"bounce_date": to_zulu_iso(bounce.bounce_date),
"is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id
}
+17 -9
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from ...core.database import get_db
from ...core.datetime import utc_now
from ...models.models import Event, EventRSVP, User, EventStatus
from ...schemas import (
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
@@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
def _utc_time_string(value) -> str:
return value.strftime("%H:%M")
@router.get("/", response_model=List[EventResponse])
async def get_events(
current_user: User = Depends(get_current_active_user),
@@ -34,9 +38,9 @@ async def get_upcoming_events(
db: Session = Depends(get_db)
):
"""Get upcoming events"""
now = datetime.now()
now = utc_now()
events = db.query(Event).filter(
Event.event_date >= now.date(),
Event.event_date >= now,
Event.status == EventStatus.PUBLISHED
).order_by(Event.event_date).all()
return events
@@ -50,7 +54,7 @@ async def create_event(
):
"""Create a new event (admin only)"""
# Validate event date is in the future
if event_data.event_date < datetime.now():
if event_data.event_date < utc_now():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Event date must be in the future"
@@ -60,7 +64,7 @@ async def create_event(
title=event_data.title,
description=event_data.description,
event_date=event_data.event_date,
event_time=event_data.event_time,
event_time=_utc_time_string(event_data.event_date),
location=event_data.location,
max_attendees=event_data.max_attendees,
status=EventStatus.DRAFT,
@@ -89,10 +93,14 @@ async def update_event(
)
# Update fields
for field, value in event_data.dict(exclude_unset=True).items():
update_data = event_data.model_dump(exclude_unset=True)
if "event_date" in update_data:
update_data["event_time"] = _utc_time_string(update_data["event_date"])
for field, value in update_data.items():
setattr(event, field, value)
event.updated_at = datetime.now()
event.updated_at = utc_now()
db.commit()
db.refresh(event)
return event
@@ -167,7 +175,7 @@ async def create_or_update_rsvp(
existing_rsvp.status = rsvp_data.status
if rsvp_data.notes is not None:
existing_rsvp.notes = rsvp_data.notes
existing_rsvp.updated_at = datetime.now()
existing_rsvp.updated_at = utc_now()
db.commit()
db.refresh(existing_rsvp)
return existing_rsvp
@@ -204,4 +212,4 @@ async def get_my_rsvps(
):
"""Get current user's RSVPs"""
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
return rsvps
return rsvps
+6 -5
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from ...core.database import get_db
from ...core.datetime import unix_ms_utc, utc_now
from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
from ...schemas import (
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
@@ -121,7 +122,7 @@ async def update_payment(
# If marking as completed, set payment_date if not already set
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date:
update_data["payment_date"] = datetime.utcnow()
update_data["payment_date"] = utc_now()
for field, value in update_data.items():
setattr(payment, field, value)
@@ -182,7 +183,7 @@ async def process_square_payment(
)
# Create a reference ID for tracking
reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}"
reference_id = f"user_{current_user.id}_tier_{tier.id}_{unix_ms_utc(utc_now())}"
# Process payment with Square
square_result = await square_service.create_payment(
@@ -204,7 +205,7 @@ async def process_square_payment(
# Payment succeeded - create membership and payment records in a transaction
try:
# Calculate membership dates
start_date = datetime.utcnow().date()
start_date = utc_now().date()
end_date = start_date + relativedelta(years=1)
# Create membership with ACTIVE status
@@ -226,7 +227,7 @@ async def process_square_payment(
payment_method=PaymentMethod.SQUARE,
status=PaymentStatus.COMPLETED,
transaction_id=square_result.get('payment_id'),
payment_date=datetime.utcnow(),
payment_date=utc_now(),
notes=payment_request.note
)
db.add(payment)
@@ -389,7 +390,7 @@ async def record_manual_payment(
payment_method=payment_data.payment_method,
notes=payment_data.notes,
status=PaymentStatus.COMPLETED,
payment_date=datetime.utcnow()
payment_date=utc_now()
)
db.add(payment)
+3 -2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.datetime import utc_now
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
from ...schemas import (
MessageResponse,
@@ -691,11 +692,11 @@ async def send_user_password_reset(
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
PasswordResetToken.expires_at > utc_now()
).update({"used": True})
reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1)
expires_at = utc_now() + timedelta(hours=1)
db_token = PasswordResetToken(
user_id=user.id,
+26 -2
View File
@@ -1,10 +1,14 @@
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional, Union, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
from .datetime import utc_now
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
MACHINE_TOKEN_PREFIX = "sha256$"
def create_access_token(
@@ -12,9 +16,9 @@ def create_access_token(
) -> str:
"""Create JWT access token"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = utc_now() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
expire = utc_now() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
@@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def get_machine_token_hash(token: str) -> str:
"""Hash a machine token for fast constant-time verification."""
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
return f"{MACHINE_TOKEN_PREFIX}{digest}"
def verify_machine_token(token: str, stored_hash: str) -> bool:
"""Verify a machine token, supporting legacy bcrypt hashes during migration."""
if not stored_hash:
return False
if stored_hash.startswith(MACHINE_TOKEN_PREFIX):
expected_hash = get_machine_token_hash(token)
return hmac.compare_digest(expected_hash, stored_hash)
return verify_password(token, stored_hash)
def is_machine_token_hash(stored_hash: str | None) -> bool:
return bool(stored_hash and stored_hash.startswith(MACHINE_TOKEN_PREFIX))
def decode_token(token: str) -> Optional[str]:
"""Decode JWT token and return subject"""
try:
+36 -2
View File
@@ -1,13 +1,30 @@
import asyncio
import time
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Request
from contextlib import asynccontextmanager
from .core.config import settings
from .api.v1 import api_router
from .core.database import get_db
from .core.database import SessionLocal, get_db
from .core.init_db import init_default_data
from .services.attendance_service import close_stale_attendance_sessions
from sqlalchemy.orm import Session
async def close_stale_attendance_loop():
"""Periodically close forgotten RFID check-ins after midnight."""
while True:
await asyncio.sleep(3600)
db = SessionLocal()
try:
close_stale_attendance_sessions(db)
except Exception as exc:
print(f"Failed to close stale attendance sessions: {exc}")
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle startup and shutdown events"""
@@ -15,13 +32,20 @@ async def lifespan(app: FastAPI):
db: Session = next(get_db())
try:
init_default_data(db)
close_stale_attendance_sessions(db)
finally:
db.close()
attendance_task = asyncio.create_task(close_stale_attendance_loop())
yield
# Shutdown (if needed)
pass
attendance_task.cancel()
try:
await attendance_task
except asyncio.CancelledError:
pass
app = FastAPI(
@@ -40,6 +64,16 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def add_request_timing_headers(request: Request, call_next):
started_at = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - started_at) * 1000
response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}"
response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}"
return response
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
+175 -33
View File
@@ -3,9 +3,9 @@ from sqlalchemy import (
Float, Text, ForeignKey, Date, UniqueConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..core.database import Base
from ..core.datetime import utc_now
class UserRole(str, enum.Enum):
@@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum):
MAYBE = "maybe"
class EspReaderType(str, enum.Enum):
CHECKIN_CHECKOUT = "checkin_checkout"
class EspReaderProvisioningStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
PROVISIONED = "provisioned"
REJECTED = "rejected"
class EspTapAction(str, enum.Enum):
CHECK_IN = "check_in"
CHECK_OUT = "check_out"
DENIED = "denied"
UNKNOWN = "unknown"
class AttendanceCheckoutSource(str, enum.Enum):
USER = "user"
SYSTEM = "system"
class RfidWriteJobStatus(str, enum.Enum):
PENDING = "pending"
CLAIMED = "claimed"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class User(Base):
__tablename__ = "users"
@@ -62,8 +93,8 @@ class User(Base):
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
volunteer_level = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
last_login = Column(DateTime, nullable=True)
# Relationships
@@ -78,6 +109,8 @@ class User(Base):
cascade="all, delete-orphan",
foreign_keys="UserProfileAnswer.user_id"
)
rfid_cards = relationship("RfidCard", back_populates="user")
attendance_sessions = relationship("AttendanceSession", back_populates="user")
class ProfileQuestion(Base):
@@ -96,8 +129,8 @@ class ProfileQuestion(Base):
display_order = Column(Integer, default=0, nullable=False)
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
depends_on_value = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
@@ -114,8 +147,8 @@ class UserProfileAnswer(Base):
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
value_text = Column(Text, nullable=True)
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
question = relationship("ProfileQuestion", back_populates="answers")
@@ -131,8 +164,8 @@ class MembershipTier(Base):
annual_fee = Column(Float, nullable=False)
benefits = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
memberships = relationship("Membership", back_populates="tier")
@@ -148,8 +181,8 @@ class Membership(Base):
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
auto_renew = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
user = relationship("User", back_populates="memberships")
@@ -169,8 +202,8 @@ class Payment(Base):
transaction_id = Column(String(255), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
user = relationship("User", back_populates="payments")
@@ -189,8 +222,8 @@ class Event(Base):
max_attendees = Column(Integer, nullable=True)
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
@@ -205,14 +238,123 @@ class EventRSVP(Base):
status = Column(SQLEnum(RSVPStatus, values_callable=lambda x: [e.value for e in x]), default=RSVPStatus.PENDING, nullable=False)
attended = Column(Boolean, default=False, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
event = relationship("Event", back_populates="rsvps")
user = relationship("User", back_populates="event_rsvps")
class EspReader(Base):
__tablename__ = "esp_readers"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(String(100), unique=True, index=True, nullable=False)
name = Column(String(255), nullable=False)
location = Column(String(255), nullable=True)
reader_type = Column(SQLEnum(EspReaderType, values_callable=lambda x: [e.value for e in x]), default=EspReaderType.CHECKIN_CHECKOUT, nullable=False)
provisioning_status = Column(SQLEnum(EspReaderProvisioningStatus, values_callable=lambda x: [e.value for e in x]), default=EspReaderProvisioningStatus.PENDING, nullable=False)
api_key_hash = Column(String(255), nullable=True)
pending_api_key = Column(String(255), nullable=True)
registration_token_hash = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
can_write_cards = Column(Boolean, default=False, nullable=False)
firmware_version = Column(String(100), nullable=True)
last_seen_at = Column(DateTime, nullable=True)
approved_at = Column(DateTime, nullable=True)
provisioned_at = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
taps = relationship("RfidTap", back_populates="reader")
attendance_sessions = relationship("AttendanceSession", back_populates="reader")
write_jobs = relationship("RfidCardWriteJob", back_populates="reader")
class RfidCard(Base):
__tablename__ = "rfid_cards"
id = Column(Integer, primary_key=True, index=True)
uid = Column(String(100), unique=True, index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
label = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
user = relationship("User", back_populates="rfid_cards")
taps = relationship("RfidTap", back_populates="card")
class RfidCardWriteJob(Base):
__tablename__ = "rfid_card_write_jobs"
id = Column(Integer, primary_key=True, index=True)
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True)
label = Column(String(255), nullable=False)
status = Column(SQLEnum(RfidWriteJobStatus, values_callable=lambda x: [e.value for e in x]), default=RfidWriteJobStatus.PENDING, nullable=False, index=True)
requested_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
card_uid = Column(String(100), nullable=True, index=True)
write_payload = Column(Text, nullable=True)
claimed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
error_message = Column(String(500), nullable=True)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
reader = relationship("EspReader", back_populates="write_jobs")
user = relationship("User", foreign_keys=[user_id])
requested_by_user = relationship("User", foreign_keys=[requested_by_user_id])
card = relationship("RfidCard")
class RfidTap(Base):
__tablename__ = "rfid_taps"
id = Column(Integer, primary_key=True, index=True)
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
card_uid = Column(String(100), nullable=False, index=True)
action = Column(SQLEnum(EspTapAction, values_callable=lambda x: [e.value for e in x]), default=EspTapAction.UNKNOWN, nullable=False)
accepted = Column(Boolean, default=False, nullable=False)
message = Column(String(255), nullable=True)
raw_payload = Column(Text, nullable=True)
tapped_at = Column(DateTime, default=utc_now, nullable=False, index=True)
created_at = Column(DateTime, default=utc_now, nullable=False)
reader = relationship("EspReader", back_populates="taps")
card = relationship("RfidCard", back_populates="taps")
user = relationship("User")
class AttendanceSession(Base):
__tablename__ = "attendance_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True)
check_in_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=False)
check_out_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=True)
checked_in_at = Column(DateTime, nullable=False, index=True)
checked_out_at = Column(DateTime, nullable=True, index=True)
checkout_source = Column(SQLEnum(AttendanceCheckoutSource, values_callable=lambda x: [e.value for e in x]), nullable=True)
system_flag_reason = Column(String(255), nullable=True)
duration_seconds = Column(Integer, nullable=True)
is_open = Column(Boolean, default=True, nullable=False, index=True)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
user = relationship("User", back_populates="attendance_sessions")
reader = relationship("EspReader", back_populates="attendance_sessions")
check_in_tap = relationship("RfidTap", foreign_keys=[check_in_tap_id])
check_out_tap = relationship("RfidTap", foreign_keys=[check_out_tap_id])
class VolunteerRole(Base):
__tablename__ = "volunteer_roles"
@@ -220,8 +362,8 @@ class VolunteerRole(Base):
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
@@ -236,8 +378,8 @@ class VolunteerAssignment(Base):
assigned_date = Column(Date, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
user = relationship("User", back_populates="volunteer_assignments")
@@ -256,8 +398,8 @@ class VolunteerSchedule(Base):
location = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
assignment = relationship("VolunteerAssignment", back_populates="schedules")
@@ -275,8 +417,8 @@ class Certificate(Base):
certificate_number = Column(String(100), nullable=True)
file_path = Column(String(500), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships
user = relationship("User", back_populates="certificates")
@@ -294,8 +436,8 @@ class File(Base):
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
description = Column(Text, nullable=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class Notification(Base):
@@ -308,7 +450,7 @@ class Notification(Base):
email_sent = Column(Boolean, default=False, nullable=False)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
class PasswordResetToken(Base):
@@ -319,7 +461,7 @@ class PasswordResetToken(Base):
token = Column(String(255), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
# Relationships
user = relationship("User", backref="password_reset_tokens")
@@ -336,8 +478,8 @@ class EmailTemplate(Base):
text_body = Column(Text, nullable=True)
variables = Column(Text, nullable=True) # JSON string of available variables
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class BounceType(str, enum.Enum):
@@ -357,5 +499,5 @@ class EmailBounce(Base):
smtp2go_message_id = Column(String(255), nullable=True, index=True)
bounce_date = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
+46
View File
@@ -44,6 +44,29 @@ from .schemas import (
ProfileQuestionForUser,
ProfileAnswerUpdate,
ProfileAnswersUpdateRequest,
EspReaderCreate,
EspReaderUpdate,
EspReaderResponse,
EspReaderCreateResponse,
EspReaderRegistrationRequest,
EspReaderRegistrationResponse,
EspReaderProvisioningResponse,
RfidCardCreate,
RfidCardUpdate,
RfidCardResponse,
RfidTapRequest,
RfidTapResponse,
RfidWriteJobCreate,
RfidWriteJobCompleteRequest,
RfidWriteJobResponse,
EspTimeResponse,
EspHeartbeatRequest,
EspHeartbeatResponse,
EspDashboardLoginResponse,
RfidTapAdminResponse,
AttendanceSessionResponse,
StaleSessionCloseRequest,
StaleSessionCloseResponse,
)
__all__ = [
@@ -92,4 +115,27 @@ __all__ = [
"ProfileQuestionForUser",
"ProfileAnswerUpdate",
"ProfileAnswersUpdateRequest",
"EspReaderCreate",
"EspReaderUpdate",
"EspReaderResponse",
"EspReaderCreateResponse",
"EspReaderRegistrationRequest",
"EspReaderRegistrationResponse",
"EspReaderProvisioningResponse",
"RfidCardCreate",
"RfidCardUpdate",
"RfidCardResponse",
"RfidTapRequest",
"RfidTapResponse",
"RfidWriteJobCreate",
"RfidWriteJobCompleteRequest",
"RfidWriteJobResponse",
"EspTimeResponse",
"EspHeartbeatRequest",
"EspHeartbeatResponse",
"EspDashboardLoginResponse",
"RfidTapAdminResponse",
"AttendanceSessionResponse",
"StaleSessionCloseRequest",
"StaleSessionCloseResponse",
]
+283 -33
View File
@@ -1,11 +1,43 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator
from typing import Optional, Literal, Any
from datetime import datetime, date
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
from ..core.datetime import to_utc_naive, to_zulu_iso
from ..models.models import (
UserRole,
MembershipStatus,
PaymentStatus,
PaymentMethod,
EspReaderProvisioningStatus,
EspReaderType,
EspTapAction,
RfidWriteJobStatus,
)
class UTCBaseModel(BaseModel):
@field_validator("*", mode="before", check_fields=False)
@classmethod
def normalize_datetime_inputs(cls, value: Any) -> Any:
if isinstance(value, datetime):
return to_utc_naive(value)
return value
@field_validator("*", mode="after", check_fields=False)
@classmethod
def normalize_parsed_datetimes(cls, value: Any) -> Any:
if isinstance(value, datetime):
return to_utc_naive(value)
return value
@field_serializer("*", when_used="json", check_fields=False)
def serialize_datetime_outputs(self, value: Any) -> Any:
if isinstance(value, datetime):
return to_zulu_iso(value)
return value
# User Schemas
class UserBase(BaseModel):
class UserBase(UTCBaseModel):
email: EmailStr
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
@@ -17,7 +49,7 @@ class UserCreate(UserBase):
password: str = Field(..., min_length=8)
class UserUpdate(BaseModel):
class UserUpdate(UTCBaseModel):
email: Optional[EmailStr] = None
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
@@ -43,37 +75,37 @@ class UserInDB(UserResponse):
# Authentication Schemas
class Token(BaseModel):
class Token(UTCBaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
class TokenData(UTCBaseModel):
user_id: Optional[int] = None
class LoginRequest(BaseModel):
class LoginRequest(UTCBaseModel):
email: EmailStr
password: str
# Password Reset Schemas
class ForgotPasswordRequest(BaseModel):
class ForgotPasswordRequest(UTCBaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
class ResetPasswordRequest(UTCBaseModel):
token: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel):
class ChangePasswordRequest(UTCBaseModel):
current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8)
# Membership Tier Schemas
class MembershipTierBase(BaseModel):
class MembershipTierBase(UTCBaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
annual_fee: float = Field(..., ge=0)
@@ -84,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase):
pass
class MembershipTierUpdate(BaseModel):
class MembershipTierUpdate(UTCBaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
annual_fee: Optional[float] = Field(None, ge=0)
@@ -101,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase):
# Membership Schemas
class MembershipBase(BaseModel):
class MembershipBase(UTCBaseModel):
tier_id: int
auto_renew: bool = False
@@ -111,14 +143,14 @@ class MembershipCreate(MembershipBase):
end_date: date
class MembershipUpdate(BaseModel):
class MembershipUpdate(UTCBaseModel):
tier_id: Optional[int] = None
status: Optional[MembershipStatus] = None
end_date: Optional[date] = None
auto_renew: Optional[bool] = None
class MembershipResponse(BaseModel):
class MembershipResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -133,7 +165,7 @@ class MembershipResponse(BaseModel):
# Payment Schemas
class PaymentBase(BaseModel):
class PaymentBase(UTCBaseModel):
amount: float = Field(..., gt=0)
payment_method: PaymentMethod
notes: Optional[str] = None
@@ -143,14 +175,14 @@ class PaymentCreate(PaymentBase):
membership_id: Optional[int] = None
class PaymentUpdate(BaseModel):
class PaymentUpdate(UTCBaseModel):
status: Optional[PaymentStatus] = None
transaction_id: Optional[str] = None
payment_date: Optional[datetime] = None
notes: Optional[str] = None
class PaymentResponse(BaseModel):
class PaymentResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -166,7 +198,7 @@ class PaymentResponse(BaseModel):
# Square Payment Schemas
class SquarePaymentRequest(BaseModel):
class SquarePaymentRequest(UTCBaseModel):
"""Request schema for Square payment processing"""
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK")
tier_id: int = Field(..., description="Membership tier ID to create membership for")
@@ -176,7 +208,7 @@ class SquarePaymentRequest(BaseModel):
billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS")
class SquarePaymentResponse(BaseModel):
class SquarePaymentResponse(UTCBaseModel):
"""Response schema for Square payment"""
success: bool
payment_id: Optional[str] = None
@@ -189,7 +221,7 @@ class SquarePaymentResponse(BaseModel):
membership_id: Optional[int] = Field(None, description="Created membership ID")
class SquareRefundRequest(BaseModel):
class SquareRefundRequest(UTCBaseModel):
"""Request schema for Square payment refund"""
payment_id: int = Field(..., description="Database payment ID")
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
@@ -197,13 +229,13 @@ class SquareRefundRequest(BaseModel):
# Message Response
class MessageResponse(BaseModel):
class MessageResponse(UTCBaseModel):
message: str
detail: Optional[str] = None
# Email Template Schemas
class EmailTemplateBase(BaseModel):
class EmailTemplateBase(UTCBaseModel):
template_key: str
name: str
subject: str
@@ -216,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase):
pass
class EmailTemplateUpdate(BaseModel):
class EmailTemplateUpdate(UTCBaseModel):
name: Optional[str] = None
subject: Optional[str] = None
html_body: Optional[str] = None
@@ -235,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase):
# Event Schemas
class EventBase(BaseModel):
class EventBase(UTCBaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
event_date: datetime
@@ -248,7 +280,7 @@ class EventCreate(EventBase):
pass
class EventUpdate(BaseModel):
class EventUpdate(UTCBaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
event_date: Optional[datetime] = None
@@ -269,7 +301,7 @@ class EventResponse(EventBase):
# Event RSVP Schemas
class EventRSVPBase(BaseModel):
class EventRSVPBase(UTCBaseModel):
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
notes: Optional[str] = None
@@ -293,12 +325,12 @@ class EventRSVPResponse(EventRSVPBase):
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
class QuestionOption(BaseModel):
class QuestionOption(UTCBaseModel):
label: str = Field(..., min_length=1, max_length=100)
value: str = Field(..., min_length=1, max_length=100)
class ProfileQuestionBase(BaseModel):
class ProfileQuestionBase(UTCBaseModel):
key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: str = Field(..., min_length=2, max_length=255)
help_text: Optional[str] = None
@@ -317,7 +349,7 @@ class ProfileQuestionCreate(ProfileQuestionBase):
pass
class ProfileQuestionUpdate(BaseModel):
class ProfileQuestionUpdate(UTCBaseModel):
key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: Optional[str] = Field(None, min_length=2, max_length=255)
help_text: Optional[str] = None
@@ -332,7 +364,7 @@ class ProfileQuestionUpdate(BaseModel):
depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionResponse(BaseModel):
class ProfileQuestionResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -357,10 +389,228 @@ class ProfileQuestionForUser(ProfileQuestionResponse):
can_edit: bool = True
class ProfileAnswerUpdate(BaseModel):
class ProfileAnswerUpdate(UTCBaseModel):
question_id: int
value: Optional[Any] = None
class ProfileAnswersUpdateRequest(BaseModel):
class ProfileAnswersUpdateRequest(UTCBaseModel):
answers: list[ProfileAnswerUpdate]
# ESP RFID Reader Schemas
class EspReaderBase(UTCBaseModel):
device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$")
name: str = Field(..., min_length=1, max_length=255)
location: Optional[str] = Field(None, max_length=255)
reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT
notes: Optional[str] = None
is_active: bool = True
can_write_cards: bool = False
firmware_version: Optional[str] = Field(None, max_length=100)
class EspReaderCreate(EspReaderBase):
api_key: Optional[str] = Field(None, min_length=16, max_length=255)
class EspReaderUpdate(UTCBaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
location: Optional[str] = Field(None, max_length=255)
reader_type: Optional[EspReaderType] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
can_write_cards: Optional[bool] = None
rotate_api_key: bool = False
class EspReaderResponse(EspReaderBase):
model_config = ConfigDict(from_attributes=True)
id: int
provisioning_status: EspReaderProvisioningStatus
last_seen_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
provisioned_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class EspReaderCreateResponse(EspReaderResponse):
api_key: str
class EspReaderRegistrationRequest(UTCBaseModel):
device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$")
name: str = Field(..., min_length=1, max_length=255)
location: Optional[str] = Field(None, max_length=255)
reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT
can_write_cards: bool = False
firmware_version: Optional[str] = Field(None, max_length=100)
notes: Optional[str] = None
class EspReaderRegistrationResponse(UTCBaseModel):
device_id: str
provisioning_status: EspReaderProvisioningStatus
registration_token: str
message: str
poll_interval_seconds: int = 5
class EspReaderProvisioningResponse(UTCBaseModel):
device_id: str
provisioning_status: EspReaderProvisioningStatus
message: str
api_key: Optional[str] = None
apiKey: Optional[str] = None
poll_interval_seconds: int = 5
class RfidCardBase(UTCBaseModel):
uid: str = Field(..., min_length=2, max_length=100)
user_id: Optional[int] = None
label: Optional[str] = Field(None, max_length=255)
is_active: bool = True
class RfidCardCreate(RfidCardBase):
pass
class RfidCardUpdate(UTCBaseModel):
user_id: Optional[int] = None
label: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
class RfidCardResponse(RfidCardBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
class RfidTapRequest(UTCBaseModel):
card_uid: str = Field(..., min_length=2, max_length=100)
tapped_at: Optional[datetime] = None
reader_type: Optional[EspReaderType] = None
class RfidTapResponse(UTCBaseModel):
accepted: bool
action: EspTapAction
message: str
server_time_utc: datetime
tap_id: int
session_id: Optional[int] = None
user_id: Optional[int] = None
user_name: Optional[str] = None
checked_in_at: Optional[datetime] = None
checked_out_at: Optional[datetime] = None
duration_seconds: Optional[int] = None
class RfidWriteJobCreate(UTCBaseModel):
reader_id: int
user_id: int
label: str = Field(..., min_length=1, max_length=255)
class RfidWriteJobCompleteRequest(UTCBaseModel):
card_uid: Optional[str] = Field(None, min_length=2, max_length=100)
success: bool
error_message: Optional[str] = Field(None, max_length=500)
class RfidWriteJobResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reader_id: int
user_id: int
card_id: Optional[int] = None
label: str
status: RfidWriteJobStatus
requested_by_user_id: int
card_uid: Optional[str] = None
write_payload: Optional[str] = None
claimed_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
created_at: datetime
updated_at: datetime
class EspTimeResponse(UTCBaseModel):
server_time_utc: datetime
unix_ms: int
poll_interval_seconds: int = 3
class EspHeartbeatRequest(UTCBaseModel):
mode: str = Field(..., max_length=50)
message: Optional[str] = Field(None, max_length=255)
wifi_rssi: Optional[int] = None
free_heap: Optional[int] = None
firmware_version: Optional[str] = Field(None, max_length=100)
active_write_job_id: Optional[int] = None
class EspHeartbeatResponse(UTCBaseModel):
ok: bool
server_time_utc: datetime
unix_ms: int
heartbeat_interval_seconds: int = 10
time_poll_interval_seconds: int = 3
write_job_poll_interval_seconds: int = 3
class EspDashboardLoginResponse(UTCBaseModel):
valid: bool
user_id: Optional[int] = None
role: Optional[UserRole] = None
user_name: Optional[str] = None
class RfidTapAdminResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reader_id: int
card_id: Optional[int] = None
user_id: Optional[int] = None
card_uid: str
action: EspTapAction
accepted: bool
message: Optional[str] = None
tapped_at: datetime
created_at: datetime
class AttendanceSessionResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
reader_id: int
check_in_tap_id: int
check_out_tap_id: Optional[int] = None
checked_in_at: datetime
checked_out_at: Optional[datetime] = None
checkout_source: Optional[str] = None
system_flag_reason: Optional[str] = None
duration_seconds: Optional[int] = None
is_open: bool
created_at: datetime
updated_at: datetime
class StaleSessionCloseRequest(UTCBaseModel):
cutoff_date: Optional[date] = None
checkout_hour: int = Field(17, ge=0, le=23)
class StaleSessionCloseResponse(UTCBaseModel):
closed_count: int
+12 -8
View File
@@ -1,6 +1,7 @@
from typing import List, Optional, Dict, Any
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from ..core.datetime import to_utc_naive, utc_now
from ..models.models import EmailBounce, BounceType
from ..core.database import get_db
@@ -38,7 +39,9 @@ class BounceService:
db = next(get_db())
if bounce_date is None:
bounce_date = datetime.utcnow()
bounce_date = utc_now()
else:
bounce_date = to_utc_naive(bounce_date)
# Check if bounce already exists for this email and type
existing_bounce = db.query(EmailBounce).filter(
@@ -54,7 +57,7 @@ class BounceService:
if smtp2go_message_id:
existing_bounce.smtp2go_message_id = smtp2go_message_id
existing_bounce.bounce_date = bounce_date
existing_bounce.updated_at = datetime.utcnow()
existing_bounce.updated_at = utc_now()
db.commit()
db.refresh(existing_bounce)
return existing_bounce
@@ -130,7 +133,7 @@ class BounceService:
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
if bounce:
bounce.is_active = False
bounce.updated_at = datetime.utcnow()
bounce.updated_at = utc_now()
db.commit()
return True
return False
@@ -189,9 +192,10 @@ class BounceService:
try:
# SMTP2GO timestamps are typically Unix timestamps
if isinstance(timestamp, (int, float)):
bounce_date = datetime.fromtimestamp(timestamp)
bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str):
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
bounce_date = to_utc_naive(bounce_date)
except (ValueError, TypeError):
pass
@@ -252,18 +256,18 @@ class BounceService:
db = next(get_db())
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
cutoff_date = utc_now() - timedelta(days=days_old)
# Only deactivate soft bounces, keep hard bounces and complaints active
result = db.query(EmailBounce).filter(
EmailBounce.bounce_type == BounceType.SOFT,
EmailBounce.is_active == True,
EmailBounce.bounce_date < cutoff_date
).update({'is_active': False, 'updated_at': datetime.utcnow()})
).update({'is_active': False, 'updated_at': utc_now()})
db.commit()
return result
# Create a singleton instance
bounce_service = BounceService()
bounce_service = BounceService()
+2 -1
View File
@@ -2,6 +2,7 @@ import httpx
from typing import List, Optional, Dict, Any
from datetime import datetime
from ..core.database import get_db
from ..core.datetime import utc_now
from ..models.models import EmailTemplate
from sqlalchemy.orm import Session
from ..core.config import settings
@@ -147,7 +148,7 @@ class EmailService:
"payment_amount": f"£{payment_amount:.2f}",
"payment_method": payment_method,
"renewal_date": renewal_date,
"payment_date": datetime.now().strftime("%d %B %Y"),
"payment_date": utc_now().strftime("%d %B %Y"),
"app_name": settings.APP_NAME
}
return await self.send_templated_email("membership_activation", to_email, variables, db)