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:
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user