浏览代码

feat: implement sms status monitoring

clovis 2 月之前
父节点
当前提交
5fe072a956

+ 1 - 1
.env.example

@@ -21,4 +21,4 @@ TEST_DATABASE_DB=test_db
 BATCH_SMS_PHONE_NUMBER="01 10 10 10 10"
 BATCH_SMS_PHONE_NUMBER="01 10 10 10 10"
 
 
 FIRST_SUPERUSER_EMAIL=example@example.com
 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,
     sms,
     tags,
     tags,
     templates,
     templates,
+    sms_sender,
 )
 )
 
 
 api_router = APIRouter()
 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(volunteers.router, tags=["volunteer"])
 api_router.include_router(sms.router, tags=["sms"])
 api_router.include_router(sms.router, tags=["sms"])
 api_router.include_router(templates.router, tags=["template"])
 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
 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(
 async def list_project_slots(
     project_id: UUID,
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -38,7 +38,7 @@ async def list_project_slots(
     return results.scalars().all()
     return results.scalars().all()
 
 
 
 
-@router.post("/project/{project_id}/slot", response_model=SlotResponse)
+@router.post("/slot", response_model=SlotResponse)
 async def create_slot(
 async def create_slot(
     project_id: UUID,
     project_id: UUID,
     new_slot: SlotCreateRequest,
     new_slot: SlotCreateRequest,
@@ -72,7 +72,7 @@ async def create_slot(
     return 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(
 async def update_slot(
     project_id: UUID,
     project_id: UUID,
     slot_id: UUID,
     slot_id: UUID,
@@ -124,7 +124,7 @@ async def update_slot(
     return slot
     return slot
 
 
 
 
-@router.delete("/project/{project_id}/slot/{slot_id}")
+@router.delete("/slot/{slot_id}")
 async def delete_slot(
 async def delete_slot(
     project_id: UUID,
     project_id: UUID,
     slot_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 uuid import UUID
 
 
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -16,10 +14,10 @@ from app.models import (
 from app.schemas.requests import SmsCreateRequest, SmsUpdateRequest
 from app.schemas.requests import SmsCreateRequest, SmsUpdateRequest
 from app.schemas.responses import SMSResponse
 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(
 async def list_project_sms(
     project_id: UUID,
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -34,7 +32,7 @@ async def list_project_sms(
     return results.scalars().all()
     return results.scalars().all()
 
 
 
 
-@router.post("/project/{project_id}/sms", response_model=SMSResponse)
+@router.post("/sms", response_model=SMSResponse)
 async def create_sms(
 async def create_sms(
     project_id: UUID,
     project_id: UUID,
     new_sms: SmsCreateRequest,
     new_sms: SmsCreateRequest,
@@ -51,7 +49,7 @@ async def create_sms(
     return 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(
 async def update_sms(
     project_id: UUID,
     project_id: UUID,
     sms_id: UUID,
     sms_id: UUID,
@@ -69,7 +67,7 @@ async def update_sms(
     return sms
     return sms
 
 
 
 
-@router.delete("/project/{project_id}/sms/{sms_id}")
+@router.delete("/sms/{sms_id}")
 async def delete_sms(
 async def delete_sms(
     project_id: UUID,
     project_id: UUID,
     sms_id: UUID,
     sms_id: UUID,
@@ -79,63 +77,3 @@ async def delete_sms(
     """Delete a sms from the project"""
     """Delete a sms from the project"""
     session.execute(delete(Sms).where((Sms.id == sms_id) & (Sms.project_id == str(project_id))))
     session.execute(delete(Sms).where((Sms.id == sms_id) & (Sms.project_id == str(project_id))))
     session.commit()
     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.requests import TagCreateRequest, TagUpdateRequest
 from app.schemas.responses import SlotResponse, TagResponse
 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(
 async def list_project_tags(
     project_id: UUID,
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -32,7 +32,7 @@ async def list_project_tags(
     return p.tags
     return p.tags
 
 
 
 
-@router.post("/project/{project_id}/tag", response_model=TagResponse)
+@router.post("/tag", response_model=TagResponse)
 async def create_tag(
 async def create_tag(
     project_id: UUID,
     project_id: UUID,
     payload: TagCreateRequest,
     payload: TagCreateRequest,
@@ -65,7 +65,7 @@ async def create_tag(
     return 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(
 async def update_tag(
     project_id: UUID,
     project_id: UUID,
     tag_id: UUID,
     tag_id: UUID,
@@ -106,7 +106,7 @@ async def update_tag(
     return 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(
 async def list_tagged_slot(
     project_id: UUID,
     project_id: UUID,
     tag_id: UUID,
     tag_id: UUID,
@@ -124,7 +124,7 @@ async def list_tagged_slot(
     return list(slot_set)
     return list(slot_set)
 
 
 
 
-@router.delete("/project/{project_id}/tag/{tag_id}")
+@router.delete("/tag/{tag_id}")
 async def delete_tag(
 async def delete_tag(
     project_id: UUID,
     project_id: UUID,
     tag_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.requests import VolunteerCreateRequest, VolunteerUpdateRequest
 from app.schemas.responses import VolunteerResponse
 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(
 async def list_project_volunteers(
     project_id: UUID,
     project_id: UUID,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -28,7 +28,7 @@ async def list_project_volunteers(
     return results.scalars().all()
     return results.scalars().all()
 
 
 
 
-@router.post("/project/{project_id}/volunteer", response_model=VolunteerResponse)
+@router.post("/volunteer", response_model=VolunteerResponse)
 async def create_volunteer(
 async def create_volunteer(
     project_id: UUID,
     project_id: UUID,
     new_volunteer: VolunteerCreateRequest,
     new_volunteer: VolunteerCreateRequest,
@@ -63,7 +63,7 @@ async def create_volunteer(
     return 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(
 async def update_volunteer(
     project_id: UUID,
     project_id: UUID,
     volunteer_id: UUID,
     volunteer_id: UUID,
@@ -102,7 +102,7 @@ async def update_volunteer(
     return volunteer
     return volunteer
 
 
 
 
-@router.delete("/project/{project_id}/volunteer/{volunteer_id}")
+@router.delete("/volunteer/{volunteer_id}")
 async def delete_volunteer(
 async def delete_volunteer(
     project_id: UUID,
     project_id: UUID,
     volunteer_id: UUID,
     volunteer_id: UUID,

+ 3 - 0
app/core/config.py

@@ -45,6 +45,8 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
 
 
+    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS:int = 180
+
     # PROJECT NAME, VERSION AND DESCRIPTION
     # PROJECT NAME, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     VERSION: str = PYPROJECT_CONTENT["version"]
@@ -96,4 +98,5 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
 
 
+
 settings: Settings = Settings()  # type: ignore
 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))
     phone_number: Mapped[str] = mapped_column(String(24))
     sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
     sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
     send_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
     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 datetime import datetime
 from typing import Optional
 from typing import Optional
 from pydantic import BaseModel, ConfigDict, EmailStr
 from pydantic import BaseModel, ConfigDict, EmailStr
@@ -86,3 +87,16 @@ class ProjectListResponse(BaseResponse):
     updated_at: datetime
     updated_at: datetime
     name: str
     name: str
     is_public: bool
     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",
         url + "/auth/access-token",
         data={"grant_type": "password", "username": login, "password": pwd},
         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}
     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)
     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")
     logging.info(f"{len(sms_list):5} SMS a envoyer")
     # Init KDE Connect
     # Init KDE Connect
     kde = KDEConnect(device_name=device_name)
     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)
             requests.post(url + "/sms/send-now/" + sms.id, headers=headers)
         except Exception as exc:
         except Exception as exc:
             logging.warning(f"Echec lors de l'envoie du sms {sms.id}\n{str(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):
 class InvalidEnvironnementVariable(Exception):
@@ -39,8 +42,8 @@ class InvalidEnvironnementVariable(Exception):
 
 
 
 
 def get_env(key: str) -> str:
 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:
     if value is None:
         raise InvalidEnvironnementVariable(key)
         raise InvalidEnvironnementVariable(key)
     return value
     return value