diff --git a/.env.example b/.env.example index 5e14158..d9930eb 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,10 @@ EMAIL_APP_NAME=OpenJornada DEBUG=True API_HOST=0.0.0.0 API_PORT=8000 + +# SMS Configuration (LabsMobile) +# SMS_ENABLED=false +# SMS_PROVIDER=labsmobile +# SMS_LABSMOBILE_API_TOKEN= # Base64(username:api_key) for HTTP Basic auth. for example: echo -n "tu_usuario@email.com:tu_api_key" | base64 +# SMS_SENDER_ID=OpenJornada +# SMS_UNLIMITED_BALANCE=0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6da7f49 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +# OpenJornada API - Unit Tests +# Ejecuta los tests unitarios en cada push y pull request. +# Los tests no requieren MongoDB ni servicios externos. + +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: [main, develop] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run unit tests + run: python -m pytest tests/unit/ --noconftest -v diff --git a/api/auth/permissions.py b/api/auth/permissions.py index 2a402ce..c8043d6 100644 --- a/api/auth/permissions.py +++ b/api/auth/permissions.py @@ -32,7 +32,10 @@ "manage_backups", "view_reports", "export_reports", - "manage_inspection" + "manage_inspection", + "manage_sms_config", + "view_sms_logs", + "view_sms_dashboard" ], "inspector": [ "view_reports", diff --git a/api/database.py b/api/database.py index 2cb6ccc..c74dc02 100644 --- a/api/database.py +++ b/api/database.py @@ -64,6 +64,15 @@ async def init_db(): partialFilterExpression={"status": "pending"} ) + # Create indexes for SmsLogs + await db.SmsLogs.create_index("worker_id") + await db.SmsLogs.create_index("company_id") + await db.SmsLogs.create_index("time_record_entry_id") + await db.SmsLogs.create_index("status") + await db.SmsLogs.create_index("created_at") + await db.SmsLogs.create_index([("company_id", 1), ("created_at", 1)]) + await db.SmsLogs.create_index([("worker_id", 1), ("time_record_entry_id", 1), ("reminder_number", 1)]) + except Exception as e: print(f"Error initializing database: {e}") diff --git a/api/main.py b/api/main.py index 716e6df..9084fd9 100644 --- a/api/main.py +++ b/api/main.py @@ -1,3 +1,5 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import uvicorn @@ -7,7 +9,9 @@ from .database import init_db, init_default_settings from .routers import workers, time_records, auth, incidents, settings, companies, pause_types, change_requests, gdpr, backups, reports +from .routers import sms from .services.scheduler_service import scheduler_service +from .services.sms_service import sms_service load_dotenv() @@ -17,6 +21,18 @@ format='%(levelname)s: %(message)s' ) + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + await init_default_settings() + await sms_service.initialize() + await scheduler_service.start() + yield + scheduler_service.stop() + await sms_service.close() + + app = FastAPI( title="Time Tracking API", description="API for tracking workers' time entries", @@ -24,7 +40,8 @@ docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", - root_path=os.getenv("ROOT_PATH", "") + root_path=os.getenv("ROOT_PATH", ""), + lifespan=lifespan ) # CORS @@ -48,18 +65,7 @@ app.include_router(backups.router, prefix="/api", tags=["Backups"]) app.include_router(reports.router, prefix="/api", tags=["Reports & Inspection"]) app.include_router(gdpr.router, tags=["GDPR"]) - - -@app.on_event("startup") -async def startup(): - await init_db() - await init_default_settings() - await scheduler_service.start() - - -@app.on_event("shutdown") -async def shutdown(): - scheduler_service.stop() +app.include_router(sms.router, prefix="/api", tags=["SMS"]) @app.get("/", tags=["Health"]) @@ -68,7 +74,7 @@ async def health_check(): if __name__ == "__main__": - uvicorn.run("app.main:app", - host=os.getenv("API_HOST", "0.0.0.0"), - port=int(os.getenv("API_PORT", 8000)), + uvicorn.run("app.main:app", + host=os.getenv("API_HOST", "0.0.0.0"), + port=int(os.getenv("API_PORT", 8000)), reload=os.getenv("DEBUG", "False").lower() == "true") diff --git a/api/models/companies.py b/api/models/companies.py index 8cad5b2..8541257 100644 --- a/api/models/companies.py +++ b/api/models/companies.py @@ -2,6 +2,9 @@ from typing import Optional from datetime import datetime +from .sms import SmsCompanyConfig + + class CompanyBase(BaseModel): name: str = Field(..., min_length=1, max_length=200) @@ -27,3 +30,4 @@ class CompanyResponse(CompanyBase): updated_at: Optional[datetime] = None deleted_at: Optional[datetime] = None deleted_by: Optional[str] = None + sms_config: Optional[SmsCompanyConfig] = None diff --git a/api/models/settings.py b/api/models/settings.py index bea8996..d2a2975 100644 --- a/api/models/settings.py +++ b/api/models/settings.py @@ -1,6 +1,8 @@ from pydantic import BaseModel, EmailStr, Field from typing import Optional, Literal +from .sms import SmsProviderConfigInput, SmsProviderConfigStored, SmsProviderConfigResponse + # ============================================================================ # Backup Configuration Models @@ -107,13 +109,16 @@ class SettingsBase(BaseModel): class SettingsUpdate(BaseModel): contact_email: Optional[EmailStr] = None backup_config: Optional[BackupConfigInput] = None + sms_provider_config: Optional[SmsProviderConfigInput] = None class SettingsInDB(SettingsBase): id: str # MongoDB _id converted to string backup_config: Optional[BackupConfigStored] = None + sms_provider_config: Optional[SmsProviderConfigStored] = None class SettingsResponse(SettingsBase): id: str backup_config: Optional[BackupConfigResponse] = None + sms_provider_config: Optional[SmsProviderConfigResponse] = None diff --git a/api/models/sms.py b/api/models/sms.py new file mode 100644 index 0000000..ad4ec8f --- /dev/null +++ b/api/models/sms.py @@ -0,0 +1,251 @@ +import re +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Literal +from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +# ============================================================================ +# Shared Constants +# ============================================================================ + +DEFAULT_SMS_TEMPLATE = "OpenJornada: Hola {%worker_name%}, llevas {%hours_open%}h con tu jornada abierta en {%company_name%}. Si ya terminaste, no olvides registrar tu salida. Recordatorio {%reminder_number%}." + +AVAILABLE_TAGS = [ + {"tag": "{%worker_name%}", "description": "Nombre completo del trabajador", "example": "Juan García"}, + {"tag": "{%company_name%}", "description": "Nombre de la empresa", "example": "HappyAndroids"}, + {"tag": "{%hours_open%}", "description": "Horas con jornada abierta", "example": "4.5"}, + {"tag": "{%reminder_number%}", "description": "Número del recordatorio", "example": "2"}, +] + +_TIME_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") + + +# ============================================================================ +# SMS Log Models +# ============================================================================ + +class SmsSendRequest(BaseModel): + """Request body for sending a custom SMS to a worker.""" + message: str = Field(..., min_length=1, max_length=480) + + +class SmsSendResponse(BaseModel): + """Response for a custom SMS send attempt.""" + success: bool + error_message: Optional[str] = None + + +class SmsMessage(BaseModel): + """Frontend-facing SMS message/log entry (used in history).""" + id: str + worker_id: str + worker_name: Optional[str] = None + worker_id_number: Optional[str] = None # DNI + phone_number: str + message: Optional[str] = None + status: str + sent_at: Optional[datetime] = None # maps from created_at + delivered_at: Optional[datetime] = None + error_message: Optional[str] = None + + +class SmsLogResponse(BaseModel): + """API response for an SMS log entry (legacy / admin routes).""" + id: str + worker_id: str + company_id: str + phone_number: str + time_record_entry_id: str + message_type: str + reminder_number: int + status: str + provider: str + provider_message_id: Optional[str] = None + error_message: Optional[str] = None + cost_credits: float + worker_name: Optional[str] = None + worker_id_number: Optional[str] = None + message: Optional[str] = None + created_at: datetime + delivered_at: Optional[datetime] = None + + +class SmsLogListResponse(BaseModel): + """Paginated list of SMS log entries (legacy / admin routes).""" + items: list[SmsLogResponse] + total: int + page: int + page_size: int + + +class SmsHistoryResponse(BaseModel): + """Frontend-facing paginated SMS history response.""" + messages: list[SmsMessage] + total: int + skip: int + limit: int + + +# ============================================================================ +# SMS Credits Models +# ============================================================================ + +class SmsCreditsResponse(BaseModel): + """Frontend-facing SMS credits response.""" + balance: float + currency: str = "EUR" + unlimited: bool = False + provider_enabled: bool = False + last_updated: Optional[str] = None # ISO 8601 string + + +# ============================================================================ +# SMS Config Models +# ============================================================================ + +class SmsCompanyConfig(BaseModel): + """Per-company SMS reminder configuration.""" + enabled: bool = False + first_reminder_minutes: int = Field(default=240, ge=30, le=1440) + reminder_frequency_minutes: int = Field(default=60, ge=30, le=720) + max_reminders_per_day: int = Field(default=5, ge=1, le=20) + active_hours_start: str = "08:00" # HH:MM + active_hours_end: str = "23:00" # HH:MM + timezone: str = "Europe/Madrid" + + @field_validator("active_hours_start", "active_hours_end") + @classmethod + def validate_time_format(cls, v: str) -> str: + if not _TIME_PATTERN.match(v): + raise ValueError("El formato de hora debe ser HH:MM (00:00-23:59)") + return v + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, v: str) -> str: + try: + ZoneInfo(v) + except (ZoneInfoNotFoundError, KeyError): + raise ValueError(f"Zona horaria inválida: {v}") + return v + + +class SmsCompanyConfigUpdate(BaseModel): + """Partial update for company SMS config.""" + enabled: Optional[bool] = None + first_reminder_minutes: Optional[int] = Field(default=None, ge=30, le=1440) + reminder_frequency_minutes: Optional[int] = Field(default=None, ge=30, le=720) + max_reminders_per_day: Optional[int] = Field(default=None, ge=1, le=20) + active_hours_start: Optional[str] = None + active_hours_end: Optional[str] = None + timezone: Optional[str] = None + + @field_validator("active_hours_start", "active_hours_end") + @classmethod + def validate_time_format(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not _TIME_PATTERN.match(v): + raise ValueError("El formato de hora debe ser HH:MM (00:00-23:59)") + return v + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + ZoneInfo(v) + except (ZoneInfoNotFoundError, KeyError): + raise ValueError(f"Zona horaria inválida: {v}") + return v + + +class SmsWorkerConfig(BaseModel): + """Per-worker SMS opt-in/out configuration.""" + worker_id: Optional[str] = None + sms_enabled: bool = True + + +class SmsWorkerConfigUpdate(BaseModel): + """Partial update for worker SMS config.""" + sms_enabled: Optional[bool] = None + + +# ============================================================================ +# SMS Provider Config (stored in Settings) +# ============================================================================ + +class SmsProviderConfigInput(BaseModel): + """SMS provider configuration input (plain credentials).""" + provider: Literal["labsmobile"] = "labsmobile" + api_token: Optional[str] = None # LabsMobile: Base64(username:api_key) + sender_id: str = "OpenJornada" + enabled: bool = True + + +class SmsProviderConfigStored(BaseModel): + """SMS provider configuration as stored in DB (encrypted credentials).""" + provider: Literal["labsmobile"] = "labsmobile" + api_token_encrypted: str + sender_id: str = "OpenJornada" + enabled: bool = True + + +class SmsProviderConfigResponse(BaseModel): + """SMS provider configuration response (hides credentials).""" + provider: str + sender_id: str + enabled: bool + configured: bool = False + + +# ============================================================================ +# SMS Stats / Dashboard Models +# ============================================================================ + +class SmsStats(BaseModel): + """Frontend-facing SMS statistics.""" + sent_today: int + failed_today: int + pending: int + sent_this_month: int + + +class SmsDashboardCompanyStats(BaseModel): + """Per-company stats for SMS dashboard.""" + company_id: str + company_name: str + sent_today: int + sent_this_week: int + sent_this_month: int + failed_this_month: int + + +class SmsDashboardResponse(BaseModel): + """Aggregate SMS dashboard statistics.""" + total_sent_today: int + total_sent_this_week: int + total_sent_this_month: int + total_failed_this_month: int + unlimited_balance: bool + companies: list[SmsDashboardCompanyStats] + provider_enabled: bool + provider_name: str + + +# ============================================================================ +# SMS Template Models +# ============================================================================ + +class SmsTemplateResponse(BaseModel): + """Response for SMS reminder template.""" + template: str + default_template: str + available_tags: list[dict] + + +class SmsTemplateUpdate(BaseModel): + """Request body for updating the SMS reminder template.""" + template: str = Field(..., min_length=10, max_length=480) diff --git a/api/models/workers.py b/api/models/workers.py index 4332a57..7c5dcbf 100644 --- a/api/models/workers.py +++ b/api/models/workers.py @@ -2,6 +2,8 @@ from typing import Optional, ClassVar, List from datetime import datetime +from .sms import SmsWorkerConfig + class WorkerModel(BaseModel): first_name: str last_name: str @@ -22,6 +24,7 @@ class WorkerUpdateModel(BaseModel): id_number: Optional[str] = None password: Optional[str] = None # Para actualizar la contraseña company_ids: Optional[List[str]] = Field(None, min_length=1, description="Lista de IDs de empresas asociadas") + sms_enabled: Optional[bool] = None class WorkerResponse(BaseModel): id: str @@ -36,6 +39,7 @@ class WorkerResponse(BaseModel): deleted_by: Optional[str] = None company_ids: List[str] = Field(default_factory=list, description="Lista de IDs de empresas asociadas") company_names: List[str] = Field(default_factory=list, description="Nombres de las empresas asociadas") + sms_config: Optional[SmsWorkerConfig] = None # No incluimos la contraseña en la respuesta class ChangePasswordRequest(BaseModel): diff --git a/api/routers/settings.py b/api/routers/settings.py index 08869c4..815d534 100644 --- a/api/routers/settings.py +++ b/api/routers/settings.py @@ -5,6 +5,7 @@ BackupConfigInput, BackupConfigStored, BackupSchedule, S3ConfigStored, SFTPConfigStored, LocalConfig ) +from ..models.sms import SmsProviderConfigInput, SmsProviderConfigResponse from ..models.auth import APIUser from ..database import db, convert_id from ..auth.permissions import PermissionChecker @@ -49,14 +50,28 @@ def _build_backup_config_response(backup_config: dict | None) -> BackupConfigRes return response +def _build_sms_provider_config_response(sms_config: dict | None) -> SmsProviderConfigResponse | None: + """Build SMS provider config response from stored config (hides credentials).""" + if not sms_config: + return None + return SmsProviderConfigResponse( + provider=sms_config.get("provider", "labsmobile"), + sender_id=sms_config.get("sender_id", "OpenJornada"), + enabled=sms_config.get("enabled", False), + configured=bool(sms_config.get("api_token_encrypted")) + ) + + def _build_settings_response(settings: dict) -> SettingsResponse: """Build settings response from database document.""" backup_config_response = _build_backup_config_response(settings.get("backup_config")) + sms_provider_response = _build_sms_provider_config_response(settings.get("sms_provider_config")) return SettingsResponse( id=str(settings["_id"]), contact_email=settings["contact_email"], - backup_config=backup_config_response + backup_config=backup_config_response, + sms_provider_config=sms_provider_response ) @@ -113,6 +128,11 @@ async def update_settings( backup_stored = _process_backup_config(backup_input, settings.get("backup_config")) update_data["backup_config"] = backup_stored + # Handle sms_provider_config + if settings_update.sms_provider_config is not None: + sms_stored = _process_sms_provider_config(settings_update.sms_provider_config, settings.get("sms_provider_config")) + update_data["sms_provider_config"] = sms_stored + if not update_data: # No fields to update return _build_settings_response(settings) @@ -127,6 +147,11 @@ async def update_settings( if "backup_config" in update_data: await scheduler_service.reload_schedule() + # Reload SMS service if provider config changed + if "sms_provider_config" in update_data: + from ..services.sms_service import sms_service + await sms_service.reload() + # Return updated settings updated_settings = await db.Settings.find_one({"_id": settings["_id"]}) return _build_settings_response(updated_settings) @@ -186,3 +211,20 @@ def _process_backup_config(backup_input: BackupConfigInput, existing_config: dic stored["local_config"] = {"path": "/app/backups"} return stored + + +def _process_sms_provider_config(sms_input: SmsProviderConfigInput, existing_config: dict | None = None) -> dict: + """ + Process SMS provider config input and encrypt the API token. + Preserves existing encrypted credentials if api_token is not provided. + """ + result = { + "provider": sms_input.provider, + "sender_id": sms_input.sender_id, + "enabled": sms_input.enabled + } + if sms_input.api_token: + result["api_token_encrypted"] = credential_encryption.encrypt(sms_input.api_token) + elif existing_config and existing_config.get("api_token_encrypted"): + result["api_token_encrypted"] = existing_config["api_token_encrypted"] + return result diff --git a/api/routers/sms.py b/api/routers/sms.py new file mode 100644 index 0000000..1864f39 --- /dev/null +++ b/api/routers/sms.py @@ -0,0 +1,657 @@ +""" +SMS router: endpoints for SMS configuration, logs, credits, and dashboard. +""" + +import re +from datetime import date, datetime, timedelta, timezone +from typing import Optional + +from bson import ObjectId +from bson.errors import InvalidId +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from ..auth.permissions import PermissionChecker +from ..database import db, convert_id +from ..models.auth import APIUser +from ..models.sms import ( + DEFAULT_SMS_TEMPLATE, + AVAILABLE_TAGS, + SmsCompanyConfig, + SmsCompanyConfigUpdate, + SmsCreditsResponse, + SmsDashboardResponse, + SmsDashboardCompanyStats, + SmsHistoryResponse, + SmsLogListResponse, + SmsLogResponse, + SmsMessage, + SmsSendRequest, + SmsSendResponse, + SmsStats, + SmsTemplateResponse, + SmsTemplateUpdate, + SmsWorkerConfig, + SmsWorkerConfigUpdate, +) + +router = APIRouter() + +_PHONE_PATTERN = re.compile(r"^\+?[1-9]\d{6,14}$") + + +def _validate_phone_number(phone: str) -> str: + """Validate and clean phone number format.""" + cleaned = phone.strip().replace(" ", "").replace("-", "") + if not _PHONE_PATTERN.match(cleaned): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Formato de teléfono inválido: {phone}" + ) + return cleaned + + +# ============================================================================ +# Helper: resolve first active company (for routes without company_id) +# ============================================================================ + +async def _get_first_active_company() -> dict: + """Return the first non-deleted company, raising 404 if none found.""" + company = await db.Companies.find_one({"deleted_at": None}, sort=[("created_at", 1)]) + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No se encontro ninguna empresa activa" + ) + return company + + +def _doc_to_sms_message(doc: dict) -> SmsMessage: + """Convert a raw SmsLogs document to the frontend SmsMessage shape.""" + data = convert_id(doc) + return SmsMessage( + id=data["id"], + worker_id=data.get("worker_id", ""), + worker_name=data.get("worker_name"), + worker_id_number=data.get("worker_id_number"), + phone_number=data.get("phone_number", ""), + message=data.get("message"), + status=data.get("status", ""), + sent_at=data.get("created_at"), + delivered_at=data.get("delivered_at"), + error_message=data.get("error_message"), + ) + + +# ============================================================================ +# SMS Template endpoints +# ============================================================================ + +async def _build_template_response() -> SmsTemplateResponse: + """Build template response reading from DB or using default.""" + settings = await db.Settings.find_one() + template = DEFAULT_SMS_TEMPLATE + if settings and "sms_reminder_template" in settings: + template = settings["sms_reminder_template"] + return SmsTemplateResponse( + template=template, + default_template=DEFAULT_SMS_TEMPLATE, + available_tags=AVAILABLE_TAGS, + ) + + +@router.get("/sms/template", response_model=SmsTemplateResponse) +async def get_sms_template( + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Get the current SMS reminder template.""" + return await _build_template_response() + + +@router.put("/sms/template", response_model=SmsTemplateResponse) +async def update_sms_template( + body: SmsTemplateUpdate, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Update the SMS reminder template.""" + settings = await db.Settings.find_one() + if settings: + await db.Settings.update_one( + {"_id": settings["_id"]}, + {"$set": {"sms_reminder_template": body.template}}, + ) + else: + await db.Settings.insert_one({"sms_reminder_template": body.template}) + return await _build_template_response() + + +@router.delete("/sms/template", response_model=SmsTemplateResponse) +async def reset_sms_template( + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Reset the SMS reminder template to default.""" + settings = await db.Settings.find_one() + if settings: + await db.Settings.update_one( + {"_id": settings["_id"]}, + {"$unset": {"sms_reminder_template": ""}}, + ) + return await _build_template_response() + + +# ============================================================================ +# Frontend-friendly: GET /sms/config (uses first active company) +# ============================================================================ + +@router.get("/sms/config", response_model=SmsCompanyConfig) +async def get_sms_config( + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Get SMS configuration for the active company.""" + company = await _get_first_active_company() + sms_config = company.get("sms_config", {}) + return SmsCompanyConfig(**sms_config) if sms_config else SmsCompanyConfig() + + +# ============================================================================ +# Frontend-friendly: PATCH /sms/config (uses first active company) +# ============================================================================ + +@router.patch("/sms/config", response_model=SmsCompanyConfig) +async def patch_sms_config( + config_update: SmsCompanyConfigUpdate, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Partially update SMS configuration for the active company.""" + company = await _get_first_active_company() + + existing = company.get("sms_config", {}) + defaults = SmsCompanyConfig().model_dump() + merged = {**defaults, **existing} + + update_data = config_update.model_dump(exclude_unset=True) + merged.update(update_data) + + await db.Companies.update_one( + {"_id": company["_id"]}, + {"$set": {"sms_config": merged, "updated_at": datetime.now(timezone.utc)}} + ) + + return SmsCompanyConfig(**merged) + + +# ============================================================================ +# Per-company SMS Config (kept for multi-company admin) +# ============================================================================ + +@router.get("/companies/{company_id}/sms-config", response_model=SmsCompanyConfig) +async def get_company_sms_config( + company_id: str, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Get SMS configuration for a specific company.""" + try: + oid = ObjectId(company_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + company = await db.Companies.find_one({"_id": oid, "deleted_at": None}) + + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Empresa no encontrada" + ) + + sms_config = company.get("sms_config", {}) + return SmsCompanyConfig(**sms_config) if sms_config else SmsCompanyConfig() + + +@router.patch("/companies/{company_id}/sms-config", response_model=SmsCompanyConfig) +async def patch_company_sms_config( + company_id: str, + config_update: SmsCompanyConfigUpdate, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Partially update SMS configuration for a specific company.""" + try: + oid = ObjectId(company_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + company = await db.Companies.find_one({"_id": oid, "deleted_at": None}) + + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Empresa no encontrada" + ) + + existing = company.get("sms_config", {}) + defaults = SmsCompanyConfig().model_dump() + merged = {**defaults, **existing} + + update_data = config_update.model_dump(exclude_unset=True) + merged.update(update_data) + + await db.Companies.update_one( + {"_id": oid}, + {"$set": {"sms_config": merged, "updated_at": datetime.now(timezone.utc)}} + ) + + return SmsCompanyConfig(**merged) + + +# ============================================================================ +# Worker SMS Config +# ============================================================================ + +@router.get("/workers/{worker_id}/sms-config", response_model=SmsWorkerConfig) +async def get_worker_sms_config( + worker_id: str, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Get SMS configuration for a worker.""" + try: + oid = ObjectId(worker_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + worker = await db.Workers.find_one({"_id": oid, "deleted_at": None}) + + if not worker: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trabajador no encontrado" + ) + + sms_config = worker.get("sms_config", {}) + config = SmsWorkerConfig(**sms_config) if sms_config else SmsWorkerConfig() + config.worker_id = worker_id + return config + + +@router.post("/workers/{worker_id}/sms/send", response_model=SmsSendResponse) +async def send_worker_sms( + worker_id: str, + body: SmsSendRequest, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Send a custom SMS to a worker.""" + from ..services.sms_service import sms_service + + try: + oid = ObjectId(worker_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + worker = await db.Workers.find_one({"_id": oid, "deleted_at": None}) + + if not worker: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trabajador no encontrado" + ) + + phone_number = worker.get("phone_number", "") + if not phone_number: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El trabajador no tiene número de teléfono" + ) + + phone_number = _validate_phone_number(phone_number) + + company_ids = worker.get("company_ids", []) + company_id = company_ids[0] if company_ids else "" + + worker_name = f"{worker.get('first_name', '')} {worker.get('last_name', '')}".strip() + worker_id_number = worker.get("id_number", "") + + success, error_message = await sms_service.send_custom_sms( + worker_id=worker_id, + company_id=company_id, + phone_number=phone_number, + message=body.message, + worker_name=worker_name, + worker_id_number=worker_id_number, + ) + + return SmsSendResponse(success=success, error_message=error_message) + + +@router.patch("/workers/{worker_id}/sms-config", response_model=SmsWorkerConfig) +async def patch_worker_sms_config( + worker_id: str, + config_update: SmsWorkerConfigUpdate, + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Partially update SMS configuration for a worker.""" + try: + oid = ObjectId(worker_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + worker = await db.Workers.find_one({"_id": oid, "deleted_at": None}) + + if not worker: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Trabajador no encontrado" + ) + + existing = worker.get("sms_config", {}) + defaults = SmsWorkerConfig().model_dump(exclude={"worker_id"}) + merged = {**defaults, **existing} + + update_data = config_update.model_dump(exclude_unset=True) + merged.update(update_data) + + await db.Workers.update_one( + {"_id": oid}, + {"$set": {"sms_config": merged, "updated_at": datetime.now(timezone.utc)}} + ) + + result = SmsWorkerConfig(**merged) + result.worker_id = worker_id + return result + + +# ============================================================================ +# SMS History (frontend route) +# ============================================================================ + +@router.get("/sms/history", response_model=SmsHistoryResponse) +async def list_sms_history( + worker_id: Optional[str] = Query(None), + start_date: Optional[date] = Query(None), + end_date: Optional[date] = Query(None), + log_status: Optional[str] = Query(None, alias="status"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + current_user: APIUser = Depends(PermissionChecker("view_sms_logs")) +): + """List SMS messages with skip/limit pagination (frontend-friendly).""" + query: dict = {} + + if worker_id: + query["worker_id"] = worker_id + if log_status: + query["status"] = log_status + if start_date or end_date: + date_filter: dict = {} + if start_date: + date_filter["$gte"] = datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc) + if end_date: + date_filter["$lte"] = datetime.combine(end_date, datetime.max.time(), tzinfo=timezone.utc) + query["created_at"] = date_filter + + total = await db.SmsLogs.count_documents(query) + + messages: list[SmsMessage] = [] + async for doc in db.SmsLogs.find(query).sort("created_at", -1).skip(skip).limit(limit): + messages.append(_doc_to_sms_message(doc)) + + return SmsHistoryResponse(messages=messages, total=total, skip=skip, limit=limit) + + +@router.delete("/sms/history") +async def clear_sms_history( + confirm: bool = Query(False, description="Must be true to confirm deletion"), + current_user: APIUser = Depends(PermissionChecker("manage_sms_config")) +): + """Delete all SMS log entries. Requires confirm=true.""" + if not confirm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Set confirm=true to confirm deletion of all SMS history" + ) + result = await db.SmsLogs.delete_many({}) + return {"deleted": result.deleted_count} + + +@router.get("/sms/messages/{message_id}", response_model=SmsMessage) +async def get_sms_message( + message_id: str, + current_user: APIUser = Depends(PermissionChecker("view_sms_logs")) +): + """Get a single SMS message by ID (frontend-friendly).""" + try: + oid = ObjectId(message_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + doc = await db.SmsLogs.find_one({"_id": oid}) + + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Mensaje SMS no encontrado" + ) + + return _doc_to_sms_message(doc) + + +# ============================================================================ +# SMS Logs (legacy / admin routes — kept for backwards compatibility) +# ============================================================================ + +@router.get("/sms/logs", response_model=SmsLogListResponse) +async def list_sms_logs( + company_id: Optional[str] = Query(None), + worker_id: Optional[str] = Query(None), + start_date: Optional[date] = Query(None), + end_date: Optional[date] = Query(None), + log_status: Optional[str] = Query(None, alias="status"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + current_user: APIUser = Depends(PermissionChecker("view_sms_logs")) +): + """List SMS log entries with optional filters and page-based pagination.""" + query: dict = {} + + if company_id: + query["company_id"] = company_id + if worker_id: + query["worker_id"] = worker_id + if log_status: + query["status"] = log_status + if start_date or end_date: + date_filter: dict = {} + if start_date: + date_filter["$gte"] = datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc) + if end_date: + date_filter["$lte"] = datetime.combine(end_date, datetime.max.time(), tzinfo=timezone.utc) + query["created_at"] = date_filter + + total = await db.SmsLogs.count_documents(query) + skip = (page - 1) * page_size + + items: list[SmsLogResponse] = [] + async for doc in db.SmsLogs.find(query).sort("created_at", -1).skip(skip).limit(page_size): + doc_data = convert_id(doc) + items.append(SmsLogResponse(**doc_data)) + + return SmsLogListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/sms/logs/{log_id}", response_model=SmsLogResponse) +async def get_sms_log( + log_id: str, + current_user: APIUser = Depends(PermissionChecker("view_sms_logs")) +): + """Get a single SMS log entry by ID (admin/legacy).""" + try: + oid = ObjectId(log_id) + except InvalidId: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Formato de ID inválido") + doc = await db.SmsLogs.find_one({"_id": oid}) + + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Registro SMS no encontrado" + ) + + return SmsLogResponse(**convert_id(doc)) + + +# ============================================================================ +# SMS Credits — frontend route (no company_id) +# ============================================================================ + +@router.get("/sms/credits", response_model=SmsCreditsResponse) +async def get_sms_credits( + current_user: APIUser = Depends(PermissionChecker("view_sms_dashboard")) +): + """Get SMS credit/balance info for the active company (frontend-friendly).""" + from ..services.sms_service import sms_service + + unlimited = sms_service.is_unlimited_balance() + return SmsCreditsResponse( + balance=0.0, + currency="EUR", + unlimited=unlimited, + provider_enabled=sms_service.is_enabled(), + ) + + +# ============================================================================ +# SMS Stats — frontend route +# ============================================================================ + +@router.get("/sms/stats", response_model=SmsStats) +async def get_sms_stats( + current_user: APIUser = Depends(PermissionChecker("view_sms_dashboard")) +): + """Get simplified SMS statistics for the frontend dashboard.""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + sent_today = await db.SmsLogs.count_documents({ + "status": {"$in": ["sent", "delivered"]}, + "created_at": {"$gte": today_start} + }) + failed_today = await db.SmsLogs.count_documents({ + "status": "failed", + "created_at": {"$gte": today_start} + }) + sent_this_month = await db.SmsLogs.count_documents({ + "status": {"$in": ["sent", "delivered"]}, + "created_at": {"$gte": month_start} + }) + # "pending" = queued/in-flight; we don't track that state yet, so return 0 + pending = 0 + + return SmsStats( + sent_today=sent_today, + failed_today=failed_today, + pending=pending, + sent_this_month=sent_this_month, + ) + + +# ============================================================================ +# SMS Dashboard (full aggregate — kept for admin) +# ============================================================================ + +@router.get("/sms/dashboard", response_model=SmsDashboardResponse) +async def get_sms_dashboard( + current_user: APIUser = Depends(PermissionChecker("view_sms_dashboard")) +): + """Get aggregate SMS statistics for the admin dashboard.""" + from ..services.sms_service import sms_service + + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Week start (Monday) — safe subtraction avoids day-of-month underflow + week_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = week_start - timedelta(days=week_start.weekday()) + + # Month start + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + pipeline = [ + {"$match": {"created_at": {"$gte": month_start}}}, + {"$facet": { + "by_company": [ + {"$group": { + "_id": "$company_id", + "sent_today": {"$sum": {"$cond": [ + {"$and": [ + {"$gte": ["$created_at", today_start]}, + {"$in": ["$status", ["sent", "delivered"]]} + ]}, 1, 0 + ]}}, + "sent_this_week": {"$sum": {"$cond": [ + {"$and": [ + {"$gte": ["$created_at", week_start]}, + {"$in": ["$status", ["sent", "delivered"]]} + ]}, 1, 0 + ]}}, + "sent_this_month": {"$sum": {"$cond": [ + {"$in": ["$status", ["sent", "delivered"]]}, + 1, 0 + ]}}, + "failed_this_month": {"$sum": {"$cond": [ + {"$eq": ["$status", "failed"]}, + 1, 0 + ]}} + }} + ], + "totals": [ + {"$group": { + "_id": None, + "sent_today": {"$sum": {"$cond": [ + {"$and": [ + {"$gte": ["$created_at", today_start]}, + {"$in": ["$status", ["sent", "delivered"]]} + ]}, 1, 0 + ]}}, + "sent_this_week": {"$sum": {"$cond": [ + {"$and": [ + {"$gte": ["$created_at", week_start]}, + {"$in": ["$status", ["sent", "delivered"]]} + ]}, 1, 0 + ]}}, + "sent_this_month": {"$sum": {"$cond": [ + {"$in": ["$status", ["sent", "delivered"]]}, + 1, 0 + ]}}, + "failed_this_month": {"$sum": {"$cond": [ + {"$eq": ["$status", "failed"]}, + 1, 0 + ]}} + }} + ] + }} + ] + + agg_result = await db.SmsLogs.aggregate(pipeline).to_list(1) + facets = agg_result[0] if agg_result else {"by_company": [], "totals": []} + + totals = facets["totals"][0] if facets["totals"] else { + "sent_today": 0, "sent_this_week": 0, "sent_this_month": 0, "failed_this_month": 0 + } + + # Build company stats — need company names + company_stats_map = {item["_id"]: item for item in facets["by_company"]} + companies_cursor = db.Companies.find({"deleted_at": None}, {"_id": 1, "name": 1}) + company_stats: list[SmsDashboardCompanyStats] = [] + async for company in companies_cursor: + cid = str(company["_id"]) + stats = company_stats_map.get(cid, {}) + company_stats.append(SmsDashboardCompanyStats( + company_id=cid, + company_name=company.get("name", ""), + sent_today=stats.get("sent_today", 0), + sent_this_week=stats.get("sent_this_week", 0), + sent_this_month=stats.get("sent_this_month", 0), + failed_this_month=stats.get("failed_this_month", 0), + )) + + return SmsDashboardResponse( + total_sent_today=totals["sent_today"], + total_sent_this_week=totals["sent_this_week"], + total_sent_this_month=totals["sent_this_month"], + total_failed_this_month=totals["failed_this_month"], + unlimited_balance=sms_service.is_unlimited_balance(), + companies=company_stats, + provider_enabled=sms_service.is_enabled(), + provider_name="labsmobile" + ) diff --git a/api/routers/workers.py b/api/routers/workers.py index a32a8fe..3d8cb2e 100644 --- a/api/routers/workers.py +++ b/api/routers/workers.py @@ -203,6 +203,13 @@ async def update_worker( update_data["hashed_password"] = hashed_password del update_data["password"] + # Handle sms_enabled -> store in sms_config subdocument + if "sms_enabled" in update_data: + sms_enabled = update_data.pop("sms_enabled") + existing_sms_config = worker.get("sms_config", {}) + existing_sms_config["sms_enabled"] = sms_enabled + update_data["sms_config"] = existing_sms_config + # Update last modified update_data["updated_at"] = datetime.utcnow() update_data["updated_by"] = current_user.username diff --git a/api/services/scheduler_service.py b/api/services/scheduler_service.py index ff5d841..40f2f12 100644 --- a/api/services/scheduler_service.py +++ b/api/services/scheduler_service.py @@ -1,12 +1,17 @@ """ -Scheduler service for automated backups using APScheduler. +Scheduler service for automated backups and SMS reminders using APScheduler. """ +from __future__ import annotations + import asyncio import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from bson import ObjectId from ..database import db @@ -14,11 +19,12 @@ class SchedulerService: - """Manages scheduled backup jobs.""" + """Manages scheduled backup and SMS reminder jobs.""" def __init__(self): self.scheduler = AsyncIOScheduler() self._backup_job_id = "scheduled_backup" + self._sms_check_job_id = "check_open_shifts" self._started = False async def start(self): @@ -29,6 +35,7 @@ async def start(self): self.scheduler.start() self._started = True await self.reload_schedule() + self._start_sms_check_job() logger.info("Scheduler service started") def stop(self): @@ -135,6 +142,217 @@ def is_backup_scheduled(self) -> bool: """Check if backup job is scheduled.""" return self.scheduler.get_job(self._backup_job_id) is not None + # ------------------------------------------------------------------------- + # SMS reminder job + # ------------------------------------------------------------------------- + + def _start_sms_check_job(self): + """Register the open-shift SMS check job (every 5 minutes).""" + if self.scheduler.get_job(self._sms_check_job_id): + return # Already registered + + self.scheduler.add_job( + self._check_open_shifts, + IntervalTrigger(minutes=5), + id=self._sms_check_job_id, + name="Check open shifts for SMS reminders", + replace_existing=True + ) + logger.info("SMS check job registered (every 5 minutes)") + + async def _check_open_shifts(self): + """ + Periodically checks for open shifts and sends SMS reminders when due. + + Logic per company with SMS enabled: + 1. Find all open time record entries (type="entry" with no matching exit). + 2. For each open shift: + - Verify worker has sms_enabled=True and has a phone number. + - Check company active hours (in company timezone). + - Calculate how many reminders have already been sent. + - Send if time elapsed >= first_reminder_minutes/60 + (n * reminder_frequency_minutes/60) + and reminder count < max_reminders_per_day. + """ + logger.debug("[SMS-CHECK] Running open shifts check") + + try: + from .sms_service import sms_service + + if not sms_service.is_enabled(): + logger.debug("[SMS-CHECK] SMS service disabled, skipping") + return + + # Get all companies with SMS enabled + companies_cursor = db.Companies.find({ + "deleted_at": None, + "sms_config.enabled": True + }) + + async for company in companies_cursor: + try: + await self._process_company_sms(company, sms_service) + except Exception as e: + logger.error(f"[SMS-CHECK] Error processing company {company.get('_id')}: {e}") + + except Exception as e: + logger.error(f"[SMS-CHECK] Unexpected error in _check_open_shifts: {e}") + + async def _process_company_sms(self, company: dict, sms_service) -> None: + """Process SMS reminders for all open shifts in a single company.""" + company_id = str(company["_id"]) + sms_config = company.get("sms_config", {}) + + # Fields stored in minutes; convert to hours for elapsed-time comparisons + first_reminder_minutes: int = sms_config.get("first_reminder_minutes", 240) + reminder_frequency_minutes: int = sms_config.get("reminder_frequency_minutes", 60) + first_after_hours: float = first_reminder_minutes / 60.0 + repeat_interval_hours: float = reminder_frequency_minutes / 60.0 + max_reminders: int = sms_config.get("max_reminders_per_day", 5) + active_start: str = sms_config.get("active_hours_start", "08:00") + active_end: str = sms_config.get("active_hours_end", "23:00") + tz_name: str = sms_config.get("timezone", "Europe/Madrid") + + # Check if current time is within active hours + try: + company_tz = ZoneInfo(tz_name) + now_local = datetime.now(company_tz) + current_minutes = now_local.hour * 60 + now_local.minute + + start_h, start_m = map(int, active_start.split(":")) + end_h, end_m = map(int, active_end.split(":")) + active_start_minutes = start_h * 60 + start_m + active_end_minutes = end_h * 60 + end_m + + if not (active_start_minutes <= current_minutes <= active_end_minutes): + logger.debug(f"[SMS-CHECK] Company {company_id} outside active hours ({active_start}-{active_end})") + return + except Exception as e: + logger.warning(f"[SMS-CHECK] Error checking active hours for company {company_id}: {e}") + return + + now_utc = datetime.now(timezone.utc) + + # Only look at entries within a reasonable time window + max_lookback_hours = first_after_hours + (max_reminders * repeat_interval_hours) + 1 + lookback_cutoff = now_utc - timedelta(hours=max_lookback_hours) + + # Find open entry records for this company: + # An open shift = a "entry" record that has no corresponding "exit" for the same worker + # We look for entry records, then check if there's a later exit + open_entries_cursor = db.TimeRecords.find({ + "company_id": company_id, + "type": "entry", + "timestamp": {"$gte": lookback_cutoff} + }).sort("timestamp", -1) + + async for entry_record in open_entries_cursor: + try: + worker_id = entry_record.get("worker_id") + entry_id = str(entry_record["_id"]) + entry_timestamp = entry_record.get("timestamp") + + if not worker_id or not entry_timestamp: + continue + + # Make timestamp UTC-aware if naive + if entry_timestamp.tzinfo is None: + entry_timestamp = entry_timestamp.replace(tzinfo=timezone.utc) + + # Check if there's a subsequent exit for this worker at this company + exit_record = await db.TimeRecords.find_one({ + "worker_id": worker_id, + "company_id": company_id, + "type": "exit", + "timestamp": {"$gt": entry_timestamp} + }) + + if exit_record: + # Shift is closed, no reminder needed + continue + + # Calculate hours since entry + elapsed_seconds = (now_utc - entry_timestamp).total_seconds() + hours_elapsed = elapsed_seconds / 3600.0 + + if hours_elapsed < first_after_hours: + # Not enough time has passed for even the first reminder + continue + + # Count reminders already sent for this entry + reminders_sent = await db.SmsLogs.count_documents({ + "worker_id": worker_id, + "company_id": company_id, + "time_record_entry_id": entry_id, + "status": {"$in": ["sent", "delivered"]} + }) + + if reminders_sent >= max_reminders: + logger.debug( + f"[SMS-CHECK] Max reminders ({max_reminders}) reached for " + f"entry {entry_id}, worker {worker_id}" + ) + continue + + # Calculate when the next reminder is due + # Reminder N is due at: first_after_hours + (N-1) * repeat_interval_hours + # reminders_sent = N already sent, so next is N+1 + next_reminder_number = reminders_sent + 1 + hours_threshold = first_after_hours + (reminders_sent * repeat_interval_hours) + + if hours_elapsed < hours_threshold: + continue + + # Get worker details + try: + worker = await db.Workers.find_one({ + "_id": ObjectId(worker_id), + "deleted_at": None + }) + except Exception: + continue + + if not worker: + continue + + # Check worker opt-in (field renamed from opted_in to sms_enabled) + worker_sms_config = worker.get("sms_config", {}) + if not worker_sms_config.get("sms_enabled", True): + logger.debug(f"[SMS-CHECK] Worker {worker_id} has opted out of SMS") + continue + + # Use worker's phone number + phone_number = worker.get("phone_number") + if not phone_number: + logger.debug(f"[SMS-CHECK] Worker {worker_id} has no phone number") + continue + + worker_name = f"{worker.get('first_name', '')} {worker.get('last_name', '')}".strip() + worker_id_number: str | None = worker.get("id_number") + company_name = company.get("name", "") + + logger.info( + f"[SMS-CHECK] Sending reminder #{next_reminder_number} to worker {worker_id} " + f"for entry {entry_id} ({hours_elapsed:.1f}h elapsed)" + ) + + await sms_service.send_shift_reminder( + worker_id=worker_id, + company_id=company_id, + time_record_entry_id=entry_id, + phone_number=phone_number, + worker_name=worker_name, + company_name=company_name, + hours_open=hours_elapsed, + reminder_number=next_reminder_number, + worker_id_number=worker_id_number, + ) + + except Exception as e: + logger.error( + f"[SMS-CHECK] Error processing entry {entry_record.get('_id')} " + f"in company {company_id}: {e}" + ) + # Singleton scheduler_service = SchedulerService() diff --git a/api/services/sms_service.py b/api/services/sms_service.py new file mode 100644 index 0000000..a8219ba --- /dev/null +++ b/api/services/sms_service.py @@ -0,0 +1,351 @@ +""" +SMS Service for sending shift reminder notifications. + +Supports LabsMobile provider via REST JSON API. +Credentials are encrypted at rest using the same mechanism as backup_config. +""" + +import asyncio +import logging +import os +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import Optional + +import httpx + +from ..database import db +from ..models.sms import DEFAULT_SMS_TEMPLATE +from ..utils.encryption import credential_encryption + +logger = logging.getLogger(__name__) + + +def _mask_phone(phone: str) -> str: + """Mask phone number for logging, showing only last 4 digits.""" + return f"***{phone[-4:]}" if len(phone) >= 4 else "***" + + +# ============================================================================ +# Abstract Provider +# ============================================================================ + +class SmsProvider(ABC): + """Abstract base class for SMS providers.""" + + @abstractmethod + async def send_sms(self, phone_number: str, message: str) -> tuple[bool, Optional[str], Optional[str]]: + """ + Send an SMS message. + + Returns: + Tuple of (success, provider_message_id, error_message) + """ + + @abstractmethod + async def close(self) -> None: + """Close any underlying HTTP connections.""" + + +# ============================================================================ +# LabsMobile Provider +# ============================================================================ + +class LabsMobileProvider(SmsProvider): + """ + LabsMobile SMS provider implementation. + + API: https://api.labsmobile.com/json/send + Auth: HTTP Basic with base64(username:api_key) token + """ + + _API_ENDPOINT = "https://api.labsmobile.com/json/send" + + def __init__(self, api_token: str, sender_id: str = "OpenJornada"): + """ + Initialize LabsMobile provider. + + Args: + api_token: Base64-encoded "username:api_key" token for Basic auth + sender_id: SMS sender identifier (tpoa) + """ + self._api_token = api_token + self._sender_id = sender_id + self._client = httpx.AsyncClient(timeout=15.0) + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def send_sms(self, phone_number: str, message: str) -> tuple[bool, Optional[str], Optional[str]]: + """Send SMS via LabsMobile REST API.""" + payload = { + "message": message, + "tpoa": self._sender_id, + "recipient": [{"msisdn": phone_number}] + } + headers = { + "Authorization": f"Basic {self._api_token}", + "Content-Type": "application/json" + } + + try: + response = await self._client.post( + self._API_ENDPOINT, + json=payload, + headers=headers + ) + response.raise_for_status() + data = response.json() + + # LabsMobile returns {"code": "0", "message": "..."} on success + code = str(data.get("code", "")) + if code == "0": + message_id = data.get("subid") or data.get("message", "") + logger.info(f"[SMS] LabsMobile sent to {_mask_phone(phone_number)}, subid={message_id}") + return True, message_id, None + else: + error_msg = data.get("message", f"Provider error code {code}") + logger.warning(f"[SMS] LabsMobile rejected send to {_mask_phone(phone_number)}: {error_msg}") + return False, None, error_msg + + except httpx.HTTPStatusError as e: + error_msg = f"HTTP {e.response.status_code}: {e.response.text[:200]}" + logger.error(f"[SMS] LabsMobile HTTP error for {_mask_phone(phone_number)}: {error_msg}") + return False, None, error_msg + except Exception as e: + error_msg = f"{type(e).__name__}: {e}" + logger.error(f"[SMS] LabsMobile unexpected error for {_mask_phone(phone_number)}: {error_msg}") + return False, None, error_msg + + +# ============================================================================ +# SMS Service +# ============================================================================ + +class SmsService: + """ + Main SMS service managing provider, sending, and credit accounting. + + Singleton initialized on startup. Provider configuration is loaded + from Settings collection (encrypted credentials). + """ + + def __init__(self): + self._provider: Optional[SmsProvider] = None + self._enabled: bool = False + self._sender_id: str = "OpenJornada" + self._unlimited_balance: bool = False + + async def initialize(self): + """Load provider configuration from DB settings and environment.""" + # SMS balance mode + self._unlimited_balance = os.getenv("SMS_UNLIMITED_BALANCE", "0") == "1" + + # Environment variables take priority over DB config for backwards compat + env_enabled = os.getenv("SMS_ENABLED", "false").lower() == "true" + env_token = os.getenv("SMS_LABSMOBILE_API_TOKEN", "") + env_sender = os.getenv("SMS_SENDER_ID", "OpenJornada") + + if env_enabled and env_token: + self._provider = LabsMobileProvider( + api_token=env_token, + sender_id=env_sender + ) + self._enabled = True + self._sender_id = env_sender + logger.info("[SMS] Initialized from environment variables") + return + + # Try to load from DB settings + try: + settings = await db.Settings.find_one() + if settings: + sms_config = settings.get("sms_provider_config") + if sms_config and sms_config.get("enabled"): + encrypted_token = sms_config.get("api_token_encrypted", "") + if encrypted_token: + api_token = credential_encryption.decrypt(encrypted_token) + sender_id = sms_config.get("sender_id", "OpenJornada") + provider_name = sms_config.get("provider", "labsmobile") + if provider_name == "labsmobile": + self._provider = LabsMobileProvider( + api_token=api_token, + sender_id=sender_id + ) + self._enabled = True + self._sender_id = sender_id + logger.info("[SMS] Initialized from DB settings") + return + except Exception as e: + logger.error(f"[SMS] Error loading config from DB: {e}") + + logger.info("[SMS] SMS service disabled (no valid configuration)") + self._enabled = False + self._provider = None + + async def close(self): + """Close the underlying provider HTTP client, if any.""" + if self._provider is not None: + await self._provider.close() + + async def reload(self): + """Reload provider configuration (call after settings update).""" + await self.close() + self._provider = None + self._enabled = False + await self.initialize() + + def is_enabled(self) -> bool: + return self._enabled and self._provider is not None + + def is_unlimited_balance(self) -> bool: + return self._unlimited_balance + + async def _build_reminder_message( + self, + worker_name: str, + company_name: str, + hours_open: float, + reminder_number: int + ) -> str: + """Build the SMS text from the DB template or use default.""" + template = DEFAULT_SMS_TEMPLATE + try: + settings = await db.Settings.find_one() + if settings and "sms_reminder_template" in settings: + template = settings["sms_reminder_template"] + except Exception as e: + logger.error(f"[SMS] Error loading template from DB: {e}") + + message = template + message = message.replace("{%worker_name%}", worker_name) + message = message.replace("{%company_name%}", company_name) + message = message.replace("{%hours_open%}", f"{hours_open:.1f}") + message = message.replace("{%reminder_number%}", str(reminder_number)) + return message + + async def send_custom_sms( + self, + worker_id: str, + company_id: str, + phone_number: str, + message: str, + worker_name: Optional[str] = None, + worker_id_number: Optional[str] = None, + ) -> tuple[bool, Optional[str]]: + """ + Send a custom/manual SMS to a worker. + + Returns: + Tuple of (success, error_message) + """ + if not self.is_enabled(): + return False, "El servicio SMS no está habilitado" + + if not self._unlimited_balance: + return False, "SMS no disponible: saldo no configurado como ilimitado" + + success, provider_message_id, error_message = await self._provider.send_sms( + phone_number=phone_number, + message=message + ) + + now = datetime.now(timezone.utc) + sms_status = "sent" if success else "failed" + + log_entry = { + "worker_id": worker_id, + "company_id": company_id, + "phone_number": phone_number, + "time_record_entry_id": "", + "message_type": "custom", + "reminder_number": 0, + "status": sms_status, + "provider": "labsmobile", + "provider_message_id": provider_message_id, + "error_message": error_message, + "cost_credits": 1.0 if success else 0.0, + "worker_name": worker_name, + "worker_id_number": worker_id_number, + "message": message, + "created_at": now, + "delivered_at": None, + } + await db.SmsLogs.insert_one(log_entry) + + return success, error_message + + async def send_shift_reminder( + self, + worker_id: str, + company_id: str, + time_record_entry_id: str, + phone_number: str, + worker_name: str, + company_name: str, + hours_open: float, + reminder_number: int, + worker_id_number: Optional[str] = None, + ) -> bool: + """ + Send a shift reminder SMS and record it in SmsLogs / SmsCredits. + + Returns True if sent successfully, False otherwise. + """ + if not self.is_enabled(): + logger.debug("[SMS] Service disabled, skipping send_shift_reminder") + return False + + # 1. Check balance mode + if not self._unlimited_balance: + logger.warning( + f"[SMS] SMS credits via Stripe not configured yet. " + f"Set SMS_UNLIMITED_BALANCE=1 to enable unlimited sending." + ) + return False + + # 2. Build message + message = await self._build_reminder_message( + worker_name=worker_name, + company_name=company_name, + hours_open=hours_open, + reminder_number=reminder_number + ) + + # 3. Send via provider + success, provider_message_id, error_message = await self._provider.send_sms( + phone_number=phone_number, + message=message + ) + + now = datetime.now(timezone.utc) + sms_status = "sent" if success else "failed" + + # 4. Record in SmsLogs — denormalize worker fields for history display + log_entry = { + "worker_id": worker_id, + "company_id": company_id, + "phone_number": phone_number, + "time_record_entry_id": time_record_entry_id, + "message_type": "shift_reminder", + "reminder_number": reminder_number, + "status": sms_status, + "provider": "labsmobile", + "provider_message_id": provider_message_id, + "error_message": error_message, + "cost_credits": 1.0 if success else 0.0, + # Denormalized fields for frontend history display + "worker_name": worker_name, + "worker_id_number": worker_id_number, + "message": message, + "created_at": now, + "delivered_at": None + } + await db.SmsLogs.insert_one(log_entry) + + return success + + +# Singleton +sms_service = SmsService() diff --git a/requirements.txt b/requirements.txt index 08e1dd8..a295fec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,10 +18,12 @@ boto3==1.34.0 paramiko==3.4.0 cryptography==41.0.0 +# HTTP client (used by SMS service and tests) +httpx==0.25.2 + # Testing pytest==7.4.3 pytest-asyncio==0.23.2 -httpx==0.25.2 # Reports and exports openpyxl==3.1.2 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a8cdbb7..57dc8a4 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -6,4 +6,12 @@ intentionally avoids importing api.main so that missing infrastructure (MongoDB, APScheduler, etc.) does not prevent the unit test suite from running. + +Run unit tests in isolation with: + + python -m pytest tests/unit/ --noconftest -v + +The --noconftest flag is required on Python 3.9 because the parent +tests/conftest.py imports api.main, which uses the X|Y union syntax +(PEP 604) that is only valid on Python 3.10+. """ diff --git a/tests/unit/test_scheduler_sms.py b/tests/unit/test_scheduler_sms.py new file mode 100644 index 0000000..3f8a95b --- /dev/null +++ b/tests/unit/test_scheduler_sms.py @@ -0,0 +1,663 @@ +""" +Unit tests for SMS scheduler logic in SchedulerService. + +Covers _check_open_shifts() and _process_company_sms() in isolation. +No MongoDB or APScheduler instance is started; all external dependencies +are replaced with unittest.mock objects. + +Run with: + python -m pytest tests/unit/test_scheduler_sms.py --noconftest -v +""" +from __future__ import annotations + +import sys +import types +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from bson import ObjectId + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +UTC = timezone.utc + +# A fixed "now" anchored to 2026-03-14 12:00 UTC. +# In Europe/Madrid that is 13:00 (UTC+1 winter), well inside 08:00-23:00. +NOW_UTC = datetime(2026, 3, 14, 12, 0, 0, tzinfo=UTC) + +# Valid 24-character hex ObjectId strings used as stable IDs across tests. +COMPANY_OID = "aaaaaaaaaaaaaaaaaaaaaaaa" +WORKER_OID = "bbbbbbbbbbbbbbbbbbbbbbbb" +ENTRY_OID = "cccccccccccccccccccccccc" + + +# --------------------------------------------------------------------------- +# Document factory helpers +# --------------------------------------------------------------------------- + + +def _make_company( + sms_enabled: bool = True, + first_reminder_minutes: int = 240, # 4 h + reminder_frequency_minutes: int = 60, + max_reminders_per_day: int = 5, + active_hours_start: str = "08:00", + active_hours_end: str = "23:00", + timezone_name: str = "Europe/Madrid", +) -> dict: + """Build a minimal company document with SMS config.""" + return { + "_id": ObjectId(COMPANY_OID), + "name": "Test Company SL", + "deleted_at": None, + "sms_config": { + "enabled": sms_enabled, + "first_reminder_minutes": first_reminder_minutes, + "reminder_frequency_minutes": reminder_frequency_minutes, + "max_reminders_per_day": max_reminders_per_day, + "active_hours_start": active_hours_start, + "active_hours_end": active_hours_end, + "timezone": timezone_name, + }, + } + + +def _make_entry_record( + worker_id: str = WORKER_OID, + company_id: str = COMPANY_OID, + hours_ago: float = 5.0, +) -> dict: + """Build a minimal 'entry' TimeRecord document.""" + return { + "_id": ObjectId(ENTRY_OID), + "worker_id": worker_id, + "company_id": company_id, + "type": "entry", + "timestamp": NOW_UTC - timedelta(hours=hours_ago), + } + + +def _make_worker( + sms_enabled: bool = True, + phone_number="+34600000001", + first_name: str = "Ana", + last_name: str = "Garcia", +) -> dict: + """Build a minimal worker document.""" + return { + "_id": ObjectId(WORKER_OID), + "first_name": first_name, + "last_name": last_name, + "phone_number": phone_number, + "deleted_at": None, + "sms_config": { + "sms_enabled": sms_enabled, + }, + } + + +# --------------------------------------------------------------------------- +# Async cursor helper +# --------------------------------------------------------------------------- + + +def _async_cursor(documents: list) -> MagicMock: + """ + Return a MagicMock that behaves like an async Motor cursor, so that + ``async for doc in cursor`` yields each item in *documents*. + The mock also supports ``.sort(...)`` chaining. + """ + cursor = MagicMock() + cursor.__aiter__ = MagicMock(return_value=_alist(documents)) + cursor.sort = MagicMock(return_value=cursor) + return cursor + + +def _alist(items: list): + """Minimal async generator wrapping a plain list.""" + + async def _gen(): + for item in items: + yield item + + return _gen() + + +# --------------------------------------------------------------------------- +# datetime.now patcher +# --------------------------------------------------------------------------- + + +def _patch_datetime_now(pinned_utc: datetime): + """ + Return a context manager that replaces ``datetime`` in the + scheduler_service module with a subclass whose ``now()`` is frozen to + *pinned_utc*, while every other behaviour (constructors, arithmetic, ...) + is inherited from the real ``datetime`` class. + + The production code calls: + * ``datetime.now(company_tz)`` -> local time for active-hours check + * ``datetime.now(timezone.utc)`` -> UTC "now" for elapsed-time maths + """ + import api.services.scheduler_service as _sched + + real_datetime = datetime # capture the real class before patching + + class _FrozenDatetime(real_datetime): + @classmethod + def now(cls, tz=None): # type: ignore[override] + if tz is None: + return pinned_utc + return pinned_utc.astimezone(tz) + + return patch.object(_sched, "datetime", _FrozenDatetime) + + +# --------------------------------------------------------------------------- +# Fixture: SchedulerService with APScheduler mocked out +# --------------------------------------------------------------------------- + + +@pytest.fixture +def scheduler(): + """ + Instantiate SchedulerService without touching APScheduler. + + AsyncIOScheduler is patched so its constructor/start/shutdown are no-ops. + """ + with patch("api.services.scheduler_service.AsyncIOScheduler") as mock_cls: + mock_cls.return_value = MagicMock() + from api.services.scheduler_service import SchedulerService + + yield SchedulerService() + + +# --------------------------------------------------------------------------- +# Helper: inject a fake sms_service module for _check_open_shifts tests +# --------------------------------------------------------------------------- + + +def _inject_sms_module(mock_sms: MagicMock): + """ + Insert a fake ``api.services.sms_service`` module into sys.modules so + that the ``from .sms_service import sms_service`` statement inside + _check_open_shifts resolves to *mock_sms*. + + Returns the original module (or None) so the caller can restore it. + """ + fake_mod = types.ModuleType("api.services.sms_service") + fake_mod.sms_service = mock_sms # type: ignore[attr-defined] + original = sys.modules.get("api.services.sms_service") + sys.modules["api.services.sms_service"] = fake_mod + return original + + +def _restore_sms_module(original) -> None: + if original is None: + sys.modules.pop("api.services.sms_service", None) + else: + sys.modules["api.services.sms_service"] = original + + +# =========================================================================== +# Tests: _check_open_shifts() +# =========================================================================== + + +class TestCheckOpenShifts: + """Gateway tests for the top-level _check_open_shifts() dispatcher.""" + + # ------------------------------------------------------------------ + # 1. SMS disabled -> immediate return, no DB access + # ------------------------------------------------------------------ + + async def test_sms_disabled_returns_immediately_no_db_query(self, scheduler): + """ + When sms_service.is_enabled() returns False the method must bail out + before issuing any query to db.Companies. + """ + mock_sms = MagicMock() + mock_sms.is_enabled.return_value = False + + original = _inject_sms_module(mock_sms) + try: + with patch("api.services.scheduler_service.db") as mock_db: + await scheduler._check_open_shifts() + finally: + _restore_sms_module(original) + + mock_db.Companies.find.assert_not_called() + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 2. SMS enabled, no companies with sms_config.enabled=True + # ------------------------------------------------------------------ + + async def test_sms_enabled_no_matching_companies_no_sms(self, scheduler): + """ + When SMS is enabled but the cursor returns no companies (empty DB + result), send_shift_reminder must never be called. + """ + mock_sms = MagicMock() + mock_sms.is_enabled.return_value = True + + original = _inject_sms_module(mock_sms) + try: + with patch("api.services.scheduler_service.db") as mock_db: + mock_db.Companies.find.return_value = _async_cursor([]) + await scheduler._check_open_shifts() + finally: + _restore_sms_module(original) + + mock_sms.send_shift_reminder.assert_not_called() + + +# =========================================================================== +# Tests: _process_company_sms() +# =========================================================================== + + +class TestProcessCompanySms: + """ + Behaviour tests for _process_company_sms(). + + The sms_service is passed as a direct argument, so no sys.modules surgery + is required in this class. ``datetime.now`` is frozen via + _patch_datetime_now and DB collections are patched at the module level. + """ + + def _make_sms(self) -> MagicMock: + sms = MagicMock() + sms.send_shift_reminder = AsyncMock(return_value=True) + return sms + + # ------------------------------------------------------------------ + # 3. Outside active hours (before start) + # ------------------------------------------------------------------ + + async def test_outside_active_hours_before_start_no_sms(self, scheduler): + """ + Current local time is before active_hours_start -> return early; + no TimeRecords query and no SMS. + + Pinned UTC: 06:00 -> 07:00 Europe/Madrid (before 08:00 start). + """ + early_utc = datetime(2026, 3, 14, 6, 0, 0, tzinfo=UTC) + company = _make_company( + active_hours_start="08:00", + active_hours_end="23:00", + timezone_name="Europe/Madrid", + ) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(early_utc): + await scheduler._process_company_sms(company, mock_sms) + + mock_db.TimeRecords.find.assert_not_called() + mock_sms.send_shift_reminder.assert_not_called() + + async def test_outside_active_hours_after_end_no_sms(self, scheduler): + """ + Current local time is after active_hours_end -> return early. + + Pinned UTC: 23:00 -> 00:00 next-day Europe/Madrid (after 23:00 end). + """ + late_utc = datetime(2026, 3, 14, 23, 0, 0, tzinfo=UTC) # 00:00 Madrid + company = _make_company( + active_hours_start="08:00", + active_hours_end="23:00", + timezone_name="Europe/Madrid", + ) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(late_utc): + await scheduler._process_company_sms(company, mock_sms) + + mock_db.TimeRecords.find.assert_not_called() + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 4. Within active hours -- entry too recent + # ------------------------------------------------------------------ + + async def test_entry_too_recent_no_sms(self, scheduler): + """ + Entry is only 1 h old; first_reminder threshold is 4 h -> skip. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=1.0) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 5. Within active hours -- first reminder threshold reached + # ------------------------------------------------------------------ + + async def test_first_reminder_sent_when_threshold_reached(self, scheduler): + """ + Entry is 5 h old, threshold is 4 h, 0 reminders sent -> send #1. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) # no exit + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_awaited_once() + kwargs = mock_sms.send_shift_reminder.call_args.kwargs + assert kwargs["worker_id"] == WORKER_OID + assert kwargs["time_record_entry_id"] == ENTRY_OID + assert kwargs["phone_number"] == "+34600000001" + assert kwargs["reminder_number"] == 1 + assert kwargs["company_id"] == COMPANY_OID + + # ------------------------------------------------------------------ + # 6. Max reminders already reached + # ------------------------------------------------------------------ + + async def test_max_reminders_reached_no_sms(self, scheduler): + """ + reminders_sent >= max_reminders_per_day -> no SMS sent regardless of + elapsed time. + """ + max_r = 3 + company = _make_company( + first_reminder_minutes=240, + max_reminders_per_day=max_r, + ) + entry = _make_entry_record(hours_ago=10.0) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=max_r) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 7. Shift already closed + # ------------------------------------------------------------------ + + async def test_shift_closed_no_sms(self, scheduler): + """ + A matching exit record exists for the worker -> shift is closed -> + no SMS sent. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + exit_doc = { + "_id": ObjectId(), + "worker_id": WORKER_OID, + "company_id": COMPANY_OID, + "type": "exit", + "timestamp": NOW_UTC - timedelta(hours=1), + } + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=exit_doc) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 8. Worker opted out + # ------------------------------------------------------------------ + + async def test_worker_opted_out_no_sms(self, scheduler): + """Worker sms_config.sms_enabled=False -> no SMS.""" + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + worker = _make_worker(sms_enabled=False) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # 9. Worker has no phone number + # ------------------------------------------------------------------ + + async def test_worker_no_phone_number_no_sms(self, scheduler): + """Worker has phone_number=None -> no SMS.""" + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + worker = _make_worker(phone_number=None) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # Edge: repeat interval not yet reached for second reminder + # ------------------------------------------------------------------ + + async def test_second_reminder_not_yet_due(self, scheduler): + """ + One reminder already sent; repeat_interval=60 min. + Entry is 4 h 30 min old -> next threshold = 4 h + 1 h = 5 h -> skip. + """ + company = _make_company( + first_reminder_minutes=240, + reminder_frequency_minutes=60, + ) + entry = _make_entry_record(hours_ago=4.5) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=1) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # Edge: second reminder is due + # ------------------------------------------------------------------ + + async def test_second_reminder_sent_when_due(self, scheduler): + """ + One reminder already sent; entry is 5 h 30 min old. + Threshold for reminder #2 = 4 h + 1 h = 5 h -> due -> send. + """ + company = _make_company( + first_reminder_minutes=240, + reminder_frequency_minutes=60, + ) + entry = _make_entry_record(hours_ago=5.5) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=1) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_awaited_once() + kwargs = mock_sms.send_shift_reminder.call_args.kwargs + assert kwargs["reminder_number"] == 2 + assert kwargs["time_record_entry_id"] == ENTRY_OID + + # ------------------------------------------------------------------ + # Edge: naive entry timestamp treated as UTC + # ------------------------------------------------------------------ + + async def test_naive_entry_timestamp_treated_as_utc(self, scheduler): + """ + A naive (tz-unaware) entry timestamp must be treated as UTC without + raising, and elapsed-time calculation must remain correct. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + # Strip tzinfo to simulate a document stored without timezone info. + entry["timestamp"] = entry["timestamp"].replace(tzinfo=None) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + # 5 h old, 4 h threshold -> reminder must be sent. + mock_sms.send_shift_reminder.assert_awaited_once() + + # ------------------------------------------------------------------ + # Edge: entry missing worker_id is skipped silently + # ------------------------------------------------------------------ + + async def test_entry_missing_worker_id_skipped(self, scheduler): + """ + An entry document with no worker_id field must be silently skipped + without raising an exception. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + del entry["worker_id"] # simulate incomplete/corrupt document + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # Edge: worker not found in DB + # ------------------------------------------------------------------ + + async def test_worker_not_found_no_sms(self, scheduler): + """ + When db.Workers.find_one returns None (worker deleted or missing), + no SMS must be sent. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=None) # not found + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_not_called() + + # ------------------------------------------------------------------ + # Edge: exactly at the first_reminder boundary sends SMS + # ------------------------------------------------------------------ + + async def test_entry_exactly_at_threshold_sends_sms(self, scheduler): + """ + Elapsed time equals first_reminder_minutes exactly (4.0 h). + The guard is ``hours_elapsed < first_after_hours`` (strict less-than), + so an entry that is precisely 4 h old with a 4 h threshold passes and + an SMS must be sent. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=4.0) + worker = _make_worker() + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + mock_sms.send_shift_reminder.assert_awaited_once() + + # ------------------------------------------------------------------ + # Edge: worker_name and company_name forwarded correctly + # ------------------------------------------------------------------ + + async def test_send_reminder_uses_full_worker_name(self, scheduler): + """ + The worker_name passed to send_shift_reminder must be + ``f"{first_name} {last_name}".strip()`` and company_name must match. + """ + company = _make_company(first_reminder_minutes=240) + entry = _make_entry_record(hours_ago=5.0) + worker = _make_worker(first_name="Carlos", last_name="Lopez") + mock_sms = self._make_sms() + + with patch("api.services.scheduler_service.db") as mock_db, \ + _patch_datetime_now(NOW_UTC): + mock_db.TimeRecords.find.return_value = _async_cursor([entry]) + mock_db.TimeRecords.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.count_documents = AsyncMock(return_value=0) + mock_db.Workers.find_one = AsyncMock(return_value=worker) + + await scheduler._process_company_sms(company, mock_sms) + + kwargs = mock_sms.send_shift_reminder.call_args.kwargs + assert kwargs["worker_name"] == "Carlos Lopez" + assert kwargs["company_name"] == "Test Company SL" diff --git a/tests/unit/test_sms_service.py b/tests/unit/test_sms_service.py new file mode 100644 index 0000000..8922459 --- /dev/null +++ b/tests/unit/test_sms_service.py @@ -0,0 +1,496 @@ +""" +Unit tests for SmsService. + +All tests are pure unit tests — no MongoDB or HTTP server required. +External dependencies (database, encryption, HTTP) are fully mocked. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api.services.sms_service import SmsService + + +# =========================================================================== +# Helpers +# =========================================================================== + +def _make_service() -> SmsService: + """Return a fresh, uninitialised SmsService instance.""" + return SmsService() + + +def _db_settings_with_sms(enabled: bool = True, encrypted_token: str = "enc_tok") -> dict: + """Return a minimal Settings document with sms_provider_config.""" + return { + "sms_provider_config": { + "enabled": enabled, + "provider": "labsmobile", + "api_token_encrypted": encrypted_token, + "sender_id": "TestSender", + } + } + + +# =========================================================================== +# initialize() — environment-variable path +# =========================================================================== + +class TestInitializeFromEnv: + """initialize() reads SMS config from env vars when they are present.""" + + async def test_enabled_with_token_sets_enabled_true(self): + """SMS_ENABLED=true + token in env → is_enabled() returns True.""" + service = _make_service() + + env = { + "SMS_ENABLED": "true", + "SMS_LABSMOBILE_API_TOKEN": "base64token==", + "SMS_SENDER_ID": "OpenJornada", + "SMS_UNLIMITED_BALANCE": "0", + } + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": env.get(k, d)): + await service.initialize() + + assert service.is_enabled() is True + + async def test_enabled_flag_false_sets_enabled_false(self): + """SMS_ENABLED=false → is_enabled() returns False even with a token.""" + service = _make_service() + + env = { + "SMS_ENABLED": "false", + "SMS_LABSMOBILE_API_TOKEN": "base64token==", + "SMS_SENDER_ID": "OpenJornada", + "SMS_UNLIMITED_BALANCE": "0", + } + # DB returns nothing so it does not accidentally enable the service. + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": env.get(k, d)), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_enabled() is False + + async def test_env_var_not_set_defaults_to_disabled(self): + """ + When SMS_ENABLED is not set at all os.getenv returns the default "false", + so is_enabled() must return False. This is the regression test for the + bug where an unset variable was treated as enabled. + """ + service = _make_service() + + # Simulate a completely empty environment — no SMS vars present. + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_enabled() is False + + async def test_enabled_true_without_token_falls_through_to_db(self): + """SMS_ENABLED=true but empty token → env branch skipped, falls to DB.""" + service = _make_service() + + env = { + "SMS_ENABLED": "true", + "SMS_LABSMOBILE_API_TOKEN": "", # no token + "SMS_SENDER_ID": "OpenJornada", + "SMS_UNLIMITED_BALANCE": "0", + } + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": env.get(k, d)), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + # No DB config either, so must end up disabled. + assert service.is_enabled() is False + + +# =========================================================================== +# initialize() — database fallback path +# =========================================================================== + +class TestInitializeFromDb: + """initialize() falls back to DB when env vars are absent or insufficient.""" + + async def test_db_config_enabled_sets_enabled_true(self): + """Valid encrypted config in DB with enabled=true → is_enabled() True.""" + service = _make_service() + + settings_doc = _db_settings_with_sms(enabled=True, encrypted_token="enc_tok") + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db, \ + patch("api.services.sms_service.credential_encryption") as mock_enc: + + mock_db.Settings.find_one = AsyncMock(return_value=settings_doc) + mock_enc.decrypt.return_value = "decrypted_api_token" + + await service.initialize() + + assert service.is_enabled() is True + mock_enc.decrypt.assert_called_once_with("enc_tok") + + async def test_db_config_disabled_sets_enabled_false(self): + """DB config with enabled=false → is_enabled() returns False.""" + service = _make_service() + + settings_doc = _db_settings_with_sms(enabled=False) + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db, \ + patch("api.services.sms_service.credential_encryption") as mock_enc: + + mock_db.Settings.find_one = AsyncMock(return_value=settings_doc) + await service.initialize() + + assert service.is_enabled() is False + mock_enc.decrypt.assert_not_called() + + async def test_db_returns_none_leaves_service_disabled(self): + """No Settings document in DB → service stays disabled.""" + service = _make_service() + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db: + + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_enabled() is False + + async def test_db_exception_leaves_service_disabled(self): + """If DB raises an exception, service degrades gracefully to disabled.""" + service = _make_service() + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db: + + mock_db.Settings.find_one = AsyncMock(side_effect=RuntimeError("mongo down")) + await service.initialize() + + assert service.is_enabled() is False + + +# =========================================================================== +# is_enabled() +# =========================================================================== + +class TestIsEnabled: + """is_enabled() requires both _enabled=True AND a non-None provider.""" + + def test_returns_false_when_enabled_flag_true_but_no_provider(self): + """Guard: if somehow _enabled is True but provider is None → False.""" + service = _make_service() + service._enabled = True + service._provider = None + + assert service.is_enabled() is False + + def test_returns_true_when_both_flag_and_provider_set(self): + """Happy path: flag True + provider object → True.""" + service = _make_service() + service._enabled = True + service._provider = MagicMock() + + assert service.is_enabled() is True + + def test_returns_false_when_both_unset(self): + """Default state after construction → disabled.""" + service = _make_service() + + assert service.is_enabled() is False + + +# =========================================================================== +# is_unlimited_balance() +# =========================================================================== + +class TestIsUnlimitedBalance: + """is_unlimited_balance() reflects the SMS_UNLIMITED_BALANCE env var.""" + + async def test_unlimited_balance_env_var_one(self): + """SMS_UNLIMITED_BALANCE=1 → is_unlimited_balance() True.""" + service = _make_service() + + env = {"SMS_UNLIMITED_BALANCE": "1"} + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": env.get(k, d)), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_unlimited_balance() is True + + async def test_unlimited_balance_env_var_zero(self): + """SMS_UNLIMITED_BALANCE=0 (default) → is_unlimited_balance() False.""" + service = _make_service() + + env = {"SMS_UNLIMITED_BALANCE": "0"} + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": env.get(k, d)), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_unlimited_balance() is False + + async def test_unlimited_balance_not_set_defaults_false(self): + """Absent env var → default '0' → False.""" + service = _make_service() + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.initialize() + + assert service.is_unlimited_balance() is False + + +# =========================================================================== +# send_shift_reminder() +# =========================================================================== + +SHIFT_REMINDER_DEFAULTS = dict( + worker_id="w1", + company_id="c1", + time_record_entry_id="tr1", + phone_number="+34600000001", + worker_name="Ana García", + company_name="Empresa SL", + hours_open=5.0, + reminder_number=1, + worker_id_number="12345678A", +) + + +class TestSendShiftReminder: + """send_shift_reminder() guards and happy path.""" + + async def test_returns_false_when_service_disabled(self): + """Disabled service → False, provider never called.""" + service = _make_service() + mock_provider = AsyncMock() + service._provider = mock_provider + service._enabled = False # is_enabled() → False because flag is False + + result = await service.send_shift_reminder(**SHIFT_REMINDER_DEFAULTS) + + assert result is False + mock_provider.send_sms.assert_not_called() + + async def test_returns_false_when_no_unlimited_balance(self): + """Enabled service but balance not unlimited → False, provider not called.""" + service = _make_service() + mock_provider = AsyncMock() + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = False + + result = await service.send_shift_reminder(**SHIFT_REMINDER_DEFAULTS) + + assert result is False + mock_provider.send_sms.assert_not_called() + + async def test_sends_sms_and_logs_when_enabled_and_unlimited(self): + """ + Enabled + unlimited balance → provider.send_sms is called once, result + logged to SmsLogs, and the method returns True. + """ + service = _make_service() + mock_provider = AsyncMock() + mock_provider.send_sms.return_value = (True, "msg_id_123", None) + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = True + + with patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.insert_one = AsyncMock() + + result = await service.send_shift_reminder(**SHIFT_REMINDER_DEFAULTS) + + assert result is True + mock_provider.send_sms.assert_awaited_once() + mock_db.SmsLogs.insert_one.assert_awaited_once() + + # Inspect the logged document. + log_doc = mock_db.SmsLogs.insert_one.call_args[0][0] + assert log_doc["status"] == "sent" + assert log_doc["provider_message_id"] == "msg_id_123" + assert log_doc["message_type"] == "shift_reminder" + assert log_doc["worker_id"] == "w1" + assert log_doc["company_id"] == "c1" + assert log_doc["cost_credits"] == 1.0 + + async def test_logs_failed_status_when_provider_returns_error(self): + """Provider failure → returns False, log entry has status 'failed'.""" + service = _make_service() + mock_provider = AsyncMock() + mock_provider.send_sms.return_value = (False, None, "Network error") + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = True + + with patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + mock_db.SmsLogs.insert_one = AsyncMock() + + result = await service.send_shift_reminder(**SHIFT_REMINDER_DEFAULTS) + + assert result is False + log_doc = mock_db.SmsLogs.insert_one.call_args[0][0] + assert log_doc["status"] == "failed" + assert log_doc["error_message"] == "Network error" + assert log_doc["cost_credits"] == 0.0 + + +# =========================================================================== +# send_custom_sms() +# =========================================================================== + +CUSTOM_SMS_DEFAULTS = dict( + worker_id="w2", + company_id="c2", + phone_number="+34600000002", + message="Hola, este es un mensaje personalizado.", + worker_name="Luis López", + worker_id_number="87654321B", +) + + +class TestSendCustomSms: + """send_custom_sms() guards and happy path.""" + + async def test_returns_false_and_error_when_service_disabled(self): + """Disabled service → (False, error_message), provider never called.""" + service = _make_service() + mock_provider = AsyncMock() + service._enabled = False + service._provider = None + + success, error = await service.send_custom_sms(**CUSTOM_SMS_DEFAULTS) + + assert success is False + assert error is not None + assert len(error) > 0 + mock_provider.send_sms.assert_not_called() + + async def test_returns_false_when_no_unlimited_balance(self): + """Enabled but balance not unlimited → (False, error_message).""" + service = _make_service() + mock_provider = AsyncMock() + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = False + + success, error = await service.send_custom_sms(**CUSTOM_SMS_DEFAULTS) + + assert success is False + assert error is not None + mock_provider.send_sms.assert_not_called() + + async def test_sends_sms_and_logs_when_enabled_and_unlimited(self): + """ + Enabled + unlimited → provider.send_sms called, log inserted, + returns (True, None). + """ + service = _make_service() + mock_provider = AsyncMock() + mock_provider.send_sms.return_value = (True, "custom_id_456", None) + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = True + + with patch("api.services.sms_service.db") as mock_db: + mock_db.SmsLogs.insert_one = AsyncMock() + + success, error = await service.send_custom_sms(**CUSTOM_SMS_DEFAULTS) + + assert success is True + assert error is None + mock_provider.send_sms.assert_awaited_once_with( + phone_number="+34600000002", + message=CUSTOM_SMS_DEFAULTS["message"], + ) + mock_db.SmsLogs.insert_one.assert_awaited_once() + + log_doc = mock_db.SmsLogs.insert_one.call_args[0][0] + assert log_doc["message_type"] == "custom" + assert log_doc["status"] == "sent" + assert log_doc["worker_id"] == "w2" + + async def test_logs_failed_status_when_provider_returns_error(self): + """Provider failure on custom SMS → (False, error), log status='failed'.""" + service = _make_service() + mock_provider = AsyncMock() + mock_provider.send_sms.return_value = (False, None, "Auth error") + service._enabled = True + service._provider = mock_provider + service._unlimited_balance = True + + with patch("api.services.sms_service.db") as mock_db: + mock_db.SmsLogs.insert_one = AsyncMock() + + success, error = await service.send_custom_sms(**CUSTOM_SMS_DEFAULTS) + + assert success is False + assert error == "Auth error" + log_doc = mock_db.SmsLogs.insert_one.call_args[0][0] + assert log_doc["status"] == "failed" + assert log_doc["cost_credits"] == 0.0 + + +# =========================================================================== +# reload() +# =========================================================================== + +class TestReload: + """reload() must reset state before delegating to initialize().""" + + async def test_reload_resets_state_and_calls_initialize(self): + """ + After reload(), a previously-enabled service becomes disabled when + initialize() finds no valid configuration. + """ + service = _make_service() + # Simulate a previously initialised (enabled) state. + # Use AsyncMock so that await self._provider.close() works correctly. + service._enabled = True + mock_provider = MagicMock() + mock_provider.close = AsyncMock() + service._provider = mock_provider + service._unlimited_balance = True + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db: + mock_db.Settings.find_one = AsyncMock(return_value=None) + await service.reload() + + # close() must have been called on the old provider. + mock_provider.close.assert_awaited_once() + # State must have been reset and re-evaluated. + assert service.is_enabled() is False + assert service._provider is None + + async def test_reload_enables_service_when_db_now_has_config(self): + """ + reload() picks up a newly-added DB config after a settings update. + """ + service = _make_service() + # Start disabled. + service._enabled = False + service._provider = None + + settings_doc = _db_settings_with_sms(enabled=True, encrypted_token="enc_tok") + + with patch("api.services.sms_service.os.getenv", side_effect=lambda k, d="": d), \ + patch("api.services.sms_service.db") as mock_db, \ + patch("api.services.sms_service.credential_encryption") as mock_enc: + + mock_db.Settings.find_one = AsyncMock(return_value=settings_doc) + mock_enc.decrypt.return_value = "fresh_token" + + await service.reload() + + assert service.is_enabled() is True