Browse Source

feat: implement sms status monitoring

clovis 2 months ago
parent
commit
5fe072a956

+ 1 - 1
.env.example

@@ -21,4 +21,4 @@ TEST_DATABASE_DB=test_db
 BATCH_SMS_PHONE_NUMBER="01 10 10 10 10"
 
 FIRST_SUPERUSER_EMAIL=example@example.com
-FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC
+FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC

+ 2 - 0
app/api/api.py

@@ -9,6 +9,7 @@ from app.api.endpoints import (
     sms,
     tags,
     templates,
+    sms_sender,
 )
 
 api_router = APIRouter()
@@ -20,3 +21,4 @@ api_router.include_router(tags.router, tags=["tag"])
 api_router.include_router(volunteers.router, tags=["volunteer"])
 api_router.include_router(sms.router, tags=["sms"])
 api_router.include_router(templates.router, tags=["template"])
+api_router.include_router(sms_sender.router, tags=["sms_sender"])

+ 5 - 5
app/api/endpoints/slots.py

@@ -20,10 +20,10 @@ from app.schemas.requests import (
 )
 from app.schemas.responses import SlotResponse
 
-router = APIRouter()
+router = APIRouter(prefix="/project/{project_id}", tags=["project"])
 
 
-@router.get("/project/{project_id}/slots", response_model=list[SlotResponse])
+@router.get("/slots", response_model=list[SlotResponse])
 async def list_project_slots(
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
@@ -38,7 +38,7 @@ async def list_project_slots(
     return results.scalars().all()
 
 
-@router.post("/project/{project_id}/slot", response_model=SlotResponse)
+@router.post("/slot", response_model=SlotResponse)
 async def create_slot(
     project_id: UUID,
     new_slot: SlotCreateRequest,
@@ -72,7 +72,7 @@ async def create_slot(
     return slot
 
 
-@router.post("/project/{project_id}/slot/{slot_id}", response_model=SlotResponse)
+@router.post("/slot/{slot_id}", response_model=SlotResponse)
 async def update_slot(
     project_id: UUID,
     slot_id: UUID,
@@ -124,7 +124,7 @@ async def update_slot(
     return slot
 
 
-@router.delete("/project/{project_id}/slot/{slot_id}")
+@router.delete("/slot/{slot_id}")
 async def delete_slot(
     project_id: UUID,
     slot_id: UUID,

+ 6 - 68
app/api/endpoints/sms.py

@@ -1,8 +1,6 @@
-from typing import Annotated
-import datetime
 from uuid import UUID
 
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
@@ -16,10 +14,10 @@ from app.models import (
 from app.schemas.requests import SmsCreateRequest, SmsUpdateRequest
 from app.schemas.responses import SMSResponse
 
-router = APIRouter()
+router = APIRouter(prefix="/project/{project_id}", tags=["project", "sms"])
 
 
-@router.get("/project/{project_id}/sms", response_model=list[SMSResponse])
+@router.get("/sms", response_model=list[SMSResponse])
 async def list_project_sms(
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
@@ -34,7 +32,7 @@ async def list_project_sms(
     return results.scalars().all()
 
 
-@router.post("/project/{project_id}/sms", response_model=SMSResponse)
+@router.post("/sms", response_model=SMSResponse)
 async def create_sms(
     project_id: UUID,
     new_sms: SmsCreateRequest,
@@ -51,7 +49,7 @@ async def create_sms(
     return sms
 
 
-@router.post("/project/{project_id}/sms/{sms_id}", response_model=SMSResponse)
+@router.post("/sms/{sms_id}", response_model=SMSResponse)
 async def update_sms(
     project_id: UUID,
     sms_id: UUID,
@@ -69,7 +67,7 @@ async def update_sms(
     return sms
 
 
-@router.delete("/project/{project_id}/sms/{sms_id}")
+@router.delete("/sms/{sms_id}")
 async def delete_sms(
     project_id: UUID,
     sms_id: UUID,
@@ -79,63 +77,3 @@ async def delete_sms(
     """Delete a sms from the project"""
     session.execute(delete(Sms).where((Sms.id == sms_id) & (Sms.project_id == str(project_id))))
     session.commit()
-
-
-@router.get("/sms/to-send", response_model=list[SMSResponse])
-async def list_sms_to_send(
-    current_user: User = Depends(deps.get_current_user),
-    session: Session = Depends(deps.get_session),
-    max_delay: Annotated[
-        int | None, Query(description="the maximum delay a sms should be send with")
-    ] = 10,
-):
-    """List sms that should be send by now"""
-
-    now = datetime.datetime.now()
-    min_sending_time = now - datetime.timedelta(minutes=max_delay)
-    results = session.execute(
-        select(Sms).where(
-            (Sms.sending_time > min_sending_time)
-            & (Sms.sending_time < now)
-            & (Sms.send_time == None)  # noqa: E711
-        )
-    )
-    return results.scalars().all()
-
-
-@router.post("/sms/send-now/{sms_id}", response_model=SMSResponse)
-async def send_sms_now(
-    sms_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
-    session: Session = Depends(deps.get_session),
-):
-    """Update the SMS to be sent now"""
-    sms = session.get(Sms, sms_id)
-    if sms is None:
-        raise HTTPException(status_code=404, detail="SMS not found")
-    elif sms.send_time is not None:
-        raise HTTPException(status_code=400, detail="SMS has already been sent")
-    else:
-        sms.send_time = datetime.datetime.now()
-        session.commit()
-        return sms
-
-
-@router.get("/sms/not-send", response_model=list[SMSResponse])
-async def list_not_sent(
-    current_user: User = Depends(deps.get_current_user),
-    session: Session = Depends(deps.get_session),
-):
-    """List sms that are not sent"""
-    results = session.execute(select(Sms).where((Sms.send_time == None)))  # noqa: E711
-    return results.scalars().all()
-
-
-@router.get("/sms/future", response_model=list[SMSResponse])
-async def list_future_sms(
-    current_user: User = Depends(deps.get_current_user),
-    session: Session = Depends(deps.get_session),
-):
-    """List sms that should be sent in the future"""
-    results = session.execute(select(Sms).where(Sms.sending_time > datetime.datetime.now()))
-    return results.scalars().all()

+ 126 - 0
app/api/endpoints/sms_sender.py

@@ -0,0 +1,126 @@
+import datetime
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query, HTTPException, Request
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.models import User, Sms, ServerStatus
+from app.schemas.responses import SMSResponse, SMSServerStatus, EnumServerStatus
+from app.api import deps
+
+router = APIRouter(prefix="/sms-sender", tags=["sms_sender"])
+
+
+@router.get("/sms/to-send", response_model=list[SMSResponse])
+async def list_sms_to_send(
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+    max_delay: Annotated[
+        int | None, Query(description="the maximum delay a sms should be send with")
+    ] = 10,
+):
+    """List sms that should be send by now"""
+
+    now = datetime.datetime.now()
+    min_sending_time = now - datetime.timedelta(minutes=max_delay)
+    results = session.execute(
+        select(Sms).where(
+            (Sms.sending_time > min_sending_time)
+            & (Sms.sending_time < now)
+            & (Sms.send_time == None)  # noqa: E711
+        )
+    )
+    return results.scalars().all()
+
+
+@router.post("/sms/send-now/{sms_id}", response_model=SMSResponse)
+async def send_sms_now(
+    request: Request,
+    sms_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Update the SMS to be sent now"""
+    sms = session.get(Sms, sms_id)
+    if sms is None:
+        raise HTTPException(status_code=404, detail="SMS not found")
+    elif sms.send_time is not None:
+        raise HTTPException(status_code=400, detail="SMS has already been sent")
+    else:
+        sms.send_time = datetime.datetime.now()
+        session.commit()
+        await update_status(request, current_user, session)
+        return sms
+
+
+@router.get("/sms/not-send", response_model=list[SMSResponse])
+async def list_not_sent(
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """List sms that are not sent"""
+    results = session.execute(select(Sms).where((Sms.send_time == None)))  # noqa: E711
+    return results.scalars().all()
+
+
+@router.get("/sms/future", response_model=list[SMSResponse])
+async def list_future_sms(
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """List sms that should be sent in the future"""
+    results = session.execute(select(Sms).where(Sms.sending_time > datetime.datetime.now()))
+    return results.scalars().all()
+
+
+@router.post("/status")
+async def update_status(
+    request: Request,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Update the status of the SMS server"""
+    status = session.query(ServerStatus).filter_by(id=1).first()
+
+    if not status:
+        status = ServerStatus(id=1)
+
+    status.host = request.client.host
+    status.user_agent = request.headers.get("user-agent", "unknown")
+    session.add(status)
+    session.commit()
+
+    return {"message": "Status updated successfully"}
+
+
+INACTIVITY_THRESHOLD_SECONDS = 180
+
+
+@router.get("/status", response_model=SMSServerStatus)
+async def get_status(
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Get the latest status of the SMS server"""
+    status: ServerStatus | None = session.query(ServerStatus).filter_by(id=1).first()
+    now = datetime.datetime.now()
+    if not status:
+        return SMSServerStatus(
+            updated_at=now, host="N/A", user_agent="N/A", message=EnumServerStatus.INVALID
+        )
+    else:
+        msg = (
+            EnumServerStatus.INACTIVE
+            if (now - status.updated_at).total_seconds()
+            > settings.INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS
+            else EnumServerStatus.ACTIVE
+        )
+        return SMSServerStatus(
+            updated_at=status.updated_at,
+            host=status.host,
+            user_agent=status.user_agent,
+            message=msg,
+        )

+ 6 - 6
app/api/endpoints/tags.py

@@ -16,10 +16,10 @@ from app.models import (
 from app.schemas.requests import TagCreateRequest, TagUpdateRequest
 from app.schemas.responses import SlotResponse, TagResponse
 
-router = APIRouter()
+router = APIRouter(prefix="/project/{project_id}", tags=["project", "tag"])
 
 
-@router.get("/project/{project_id}/tags", response_model=list[TagResponse])
+@router.get("/tags", response_model=list[TagResponse])
 async def list_project_tags(
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
@@ -32,7 +32,7 @@ async def list_project_tags(
     return p.tags
 
 
-@router.post("/project/{project_id}/tag", response_model=TagResponse)
+@router.post("/tag", response_model=TagResponse)
 async def create_tag(
     project_id: UUID,
     payload: TagCreateRequest,
@@ -65,7 +65,7 @@ async def create_tag(
     return tag
 
 
-@router.post("/project/{project_id}/tag/{tag_id}", response_model=TagResponse)
+@router.post("/tag/{tag_id}", response_model=TagResponse)
 async def update_tag(
     project_id: UUID,
     tag_id: UUID,
@@ -106,7 +106,7 @@ async def update_tag(
     return tag
 
 
-@router.get("/project/{project_id}/tag/{tag_id}/slots", response_model=list[SlotResponse])
+@router.get("/tag/{tag_id}/slots", response_model=list[SlotResponse])
 async def list_tagged_slot(
     project_id: UUID,
     tag_id: UUID,
@@ -124,7 +124,7 @@ async def list_tagged_slot(
     return list(slot_set)
 
 
-@router.delete("/project/{project_id}/tag/{tag_id}")
+@router.delete("/tag/{tag_id}")
 async def delete_tag(
     project_id: UUID,
     tag_id: UUID,

+ 5 - 5
app/api/endpoints/volunteers.py

@@ -10,10 +10,10 @@ from app.models import Project, Slot, User, Volunteer, association_table_volunte
 from app.schemas.requests import VolunteerCreateRequest, VolunteerUpdateRequest
 from app.schemas.responses import VolunteerResponse
 
-router = APIRouter()
+router = APIRouter(prefix="/project/{project_id}", tags=["volunteers"])
 
 
-@router.get("/project/{project_id}/volunteers", response_model=list[VolunteerResponse])
+@router.get("/volunteers", response_model=list[VolunteerResponse])
 async def list_project_volunteers(
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
@@ -28,7 +28,7 @@ async def list_project_volunteers(
     return results.scalars().all()
 
 
-@router.post("/project/{project_id}/volunteer", response_model=VolunteerResponse)
+@router.post("/volunteer", response_model=VolunteerResponse)
 async def create_volunteer(
     project_id: UUID,
     new_volunteer: VolunteerCreateRequest,
@@ -63,7 +63,7 @@ async def create_volunteer(
     return volunteer
 
 
-@router.post("/project/{project_id}/volunteer/{volunteer_id}", response_model=VolunteerResponse)
+@router.post("/volunteer/{volunteer_id}", response_model=VolunteerResponse)
 async def update_volunteer(
     project_id: UUID,
     volunteer_id: UUID,
@@ -102,7 +102,7 @@ async def update_volunteer(
     return volunteer
 
 
-@router.delete("/project/{project_id}/volunteer/{volunteer_id}")
+@router.delete("/volunteer/{volunteer_id}")
 async def delete_volunteer(
     project_id: UUID,
     volunteer_id: UUID,

+ 3 - 0
app/core/config.py

@@ -45,6 +45,8 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
 
+    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS:int = 180
+
     # PROJECT NAME, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
@@ -96,4 +98,5 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
+
 settings: Settings = Settings()  # type: ignore

+ 9 - 0
app/models.py

@@ -226,3 +226,12 @@ class Sms(Base):
     phone_number: Mapped[str] = mapped_column(String(24))
     sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
     send_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
+class ServerStatus(Base):
+    __tablename__ = "server_status"
+    # Use a fixed ID to ensure we only ever have one row
+    id = Column(Integer, primary_key=True, default=1)
+    updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=func.now())
+    host = Column(String, nullable=False)
+    user_agent = Column(String)

+ 14 - 0
app/schemas/responses.py

@@ -1,3 +1,4 @@
+from enum import Enum
 from datetime import datetime
 from typing import Optional
 from pydantic import BaseModel, ConfigDict, EmailStr
@@ -86,3 +87,16 @@ class ProjectListResponse(BaseResponse):
     updated_at: datetime
     name: str
     is_public: bool
+
+
+class EnumServerStatus(Enum):
+    ACTIVE = "active"
+    INVALID = "invalid (heartbeat missing)"
+    INACTIVE = "inactive (stale)"
+
+
+class SMSServerStatus(BaseResponse):
+    updated_at: datetime
+    host: str
+    user_agent: str
+    message: EnumServerStatus

+ 9 - 6
script/send_sms.py

@@ -14,12 +14,15 @@ def send_sms_from_api(url: str, login: str, pwd: str, device_name: str):
         url + "/auth/access-token",
         data={"grant_type": "password", "username": login, "password": pwd},
     )
-    authentication = AccessTokenResponse.parse_raw(response.content)
+    authentication = AccessTokenResponse.model_validate(response.json())
     headers = {"Authorization": "Bearer " + authentication.access_token}
 
-    # List SMS to be send
+    # Notify the server you are trying to send sms
+    requests.post(url + "/status", headers=headers)
+
+    # List SMS to be sent
     response = requests.get(url + "/sms/to-send", headers=headers)
-    sms_list: list[SMSResponse] = [SMSResponse.parse_obj(obj) for obj in response.json()]
+    sms_list: list[SMSResponse] = [SMSResponse.model_validate(obj) for obj in response.json()]
     logging.info(f"{len(sms_list):5} SMS a envoyer")
     # Init KDE Connect
     kde = KDEConnect(device_name=device_name)
@@ -31,7 +34,7 @@ def send_sms_from_api(url: str, login: str, pwd: str, device_name: str):
             requests.post(url + "/sms/send-now/" + sms.id, headers=headers)
         except Exception as exc:
             logging.warning(f"Echec lors de l'envoie du sms {sms.id}\n{str(exc)}")
-        time.sleep(2)
+        time.sleep(5)
 
 
 class InvalidEnvironnementVariable(Exception):
@@ -39,8 +42,8 @@ class InvalidEnvironnementVariable(Exception):
 
 
 def get_env(key: str) -> str:
-    """Get a variable from the environnement or raise an InvalidEnvironnementVariable"""
-    value = os.environ.get(key)
+    """Get a variable from the environment or raise an InvalidEnvironnementVariable"""
+    value = os.environ.get(key, None)
     if value is None:
         raise InvalidEnvironnementVariable(key)
     return value