diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..a37eb23
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "docker"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 99
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 99
+ allow:
+ - dependency-type: "direct"
+ - dependency-type: "indirect"
diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml
new file mode 100644
index 0000000..a2f4974
--- /dev/null
+++ b/.github/workflows/staging-deploy.yml
@@ -0,0 +1,55 @@
+name: Staging Server Build Pipeline
+
+on:
+ push:
+ branches:
+ - staging
+
+jobs:
+ deploy:
+ name: Deploy to Staging Server
+ runs-on: ubuntu-latest
+ environment:
+ name: staging
+ steps:
+ - name: Execute Remote SSH Commands
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.KEY }}
+ script: |
+ set -e
+
+ echo "============================"
+ echo "Updating repository ..."
+ echo "============================"
+ if ! assembler clone --branch staging --project telemetry_aggregator; then
+ echo "❌ Error updating repository!"
+ exit 1
+ fi
+ echo "==============================="
+ echo "✅ Repository update complete"
+ echo "==============================="
+
+ echo "========================="
+ echo "Building project ..."
+ echo "========================="
+ if ! assembler deploy --project telemetry_aggregator; then
+ echo "❌ Error building project!"
+ exit 1
+ fi
+ echo "==========================="
+ echo "✅ Project build complete"
+ echo "==========================="
+
+ echo "============================="
+ echo "Cleaning up staging builds ..."
+ echo "============================="
+ if ! ${{ secrets.CLEANUP_CMD }}; then
+ echo "❌ Error cleaning up builds!"
+ exit 1
+ fi
+ echo "============================="
+ echo "✅ Cleanup complete"
+ echo "============================="
diff --git a/README.md b/README.md
index 098de5f..e2019c1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,21 @@
-# RelaySMS-Telemetry-Aggregator
+# RelaySMS Telemetry Aggregator
+
Collects, analyzes, and exposes RelaySMS usage data via a unified API for transparent telemetry insights.
+
+## References
+
+4. [REST API V1 Resources](https://api.telemetry.smswithoutborders.com/docs)
+
+## Contributing
+
+To contribute:
+
+1. Fork the repository.
+2. Create a feature branch: `git checkout -b feature-branch`.
+3. Commit your changes: `git commit -m 'Add a new feature'`.
+4. Push to the branch: `git push origin feature-branch`.
+5. Open a Pull Request.
+
+## License
+
+This project is licensed under the GNU General Public License (GPL). See the [LICENSE](LICENSE) file for details.
diff --git a/api_data_schemas.py b/api_data_schemas.py
new file mode 100644
index 0000000..26965cd
--- /dev/null
+++ b/api_data_schemas.py
@@ -0,0 +1,128 @@
+"""
+This program is free software: you can redistribute it under the terms
+of the GNU General Public License, v. 3.0. If a copy of the GNU General
+Public License was not distributed with this file, see .
+"""
+
+from typing import Literal, List, Union
+from pydantic import BaseModel, Field
+from fastapi import Query
+
+
+class SummaryDetails(BaseModel):
+ """Details of the summary metrics."""
+
+ total_signup_users: int
+ total_retained_users: int
+
+
+class SummaryParams(BaseModel):
+ """Parameters for filtering and grouping summary metrics."""
+
+ start_date: str = Field(description="Start date in 'YYYY-MM-DD' format.")
+ end_date: str = Field(description="End date in 'YYYY-MM-DD' format.")
+ country_code: str = Field(
+ default=None, description="2-character ISO region code.", max_length=2
+ )
+
+
+class SummaryResponse(BaseModel):
+ """Response model containing summary metrics."""
+
+ summary: SummaryDetails
+
+
+class MetricsParams(BaseModel):
+ """Parameters for filtering and grouping metrics."""
+
+ start_date: str = Field(description="Start date in 'YYYY-MM-DD' format.")
+ end_date: str = Field(description="End date in 'YYYY-MM-DD' format.")
+ country_code: str = Field(
+ default=None, description="2-character ISO region code.", max_length=2
+ )
+ granularity: Literal["day", "month"] = Field(
+ default="day", description="Granularity of data."
+ )
+ group_by: Literal["country", "date"] = Field(
+ default="date", description="Criteria to group results."
+ )
+ top: int = Field(
+ default=None,
+ description="Maximum number of results to return. "
+ "(cannot be used with 'page' or 'page_size')",
+ )
+ page: int = Query(default=1, ge=1, description="Page number for paginated results.")
+ page_size: int = Query(
+ default=10, ge=10, le=100, description="Number of records per page."
+ )
+
+
+class PaginationDetails(BaseModel):
+ """Pagination details for paginated responses."""
+
+ page: int
+ page_size: int
+ total_pages: int
+ total_records: int
+
+
+class CountrySignupData(BaseModel):
+ """Signup data grouped by country."""
+
+ country_code: str
+ signup_users: int
+
+
+class TimeframeSignupData(BaseModel):
+ """Signup data grouped by timeframe."""
+
+ timeframe: str
+ signup_users: int
+
+
+class SignupDetails(BaseModel):
+ """Details of the signup metrics."""
+
+ total_signup_users: int
+ pagination: PaginationDetails
+ data: List[Union[CountrySignupData, TimeframeSignupData]]
+
+
+class SignupResponse(BaseModel):
+ """Response model containing signup metrics."""
+
+ signup: SignupDetails
+
+
+class CountryRetainedData(BaseModel):
+ """Retained data grouped by country."""
+
+ country_code: str
+ retained_users: int
+
+
+class TimeframeRetainedData(BaseModel):
+ """Retained data grouped by timeframe."""
+
+ timeframe: str
+ retained_users: int
+
+
+class RetainedDetails(BaseModel):
+ """Details of the retained metrics."""
+
+ total_retained_users: int
+ pagination: PaginationDetails
+ data: List[Union[CountryRetainedData, TimeframeRetainedData]]
+
+
+class RetainedResponse(BaseModel):
+ """Response model containing retained metrics."""
+
+ retained: RetainedDetails
+
+
+class ErrorResponse(BaseModel):
+ """Response model for errors."""
+
+ error: str
diff --git a/api_v1.py b/api_v1.py
index d64723d..9ceedfc 100644
--- a/api_v1.py
+++ b/api_v1.py
@@ -4,35 +4,23 @@
Public License was not distributed with this file, see .
"""
-import datetime
+from typing import Annotated
import requests
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse
-from data_retriever import get_summary
-from response_models import SummaryResponse, ErrorResponse
+from data_retriever import get_summary, get_signup, get_retained
+from api_data_schemas import (
+ MetricsParams,
+ ErrorResponse,
+ SummaryResponse,
+ SummaryParams,
+ SignupResponse,
+ RetainedResponse,
+)
router = APIRouter(prefix="/v1", tags=["API V1"])
-def parse_date(date_str: str = None, default_days: int = 0) -> datetime.datetime:
- """
- Parse and validate the date string.
- """
- if not date_str:
- default_date = datetime.datetime.now() + datetime.timedelta(days=default_days)
- return default_date.strftime("%Y-%m-%d")
-
- try:
- return datetime.datetime.strptime(date_str, "%Y-%m-%d")
- except ValueError as e:
- raise HTTPException(
- status_code=400,
- detail={
- "error": f"Date must be in the format 'YYYY-MM-DD', but got {date_str}"
- },
- ) from e
-
-
def get_security_headers() -> dict:
"""
Return a dictionary of security headers.
@@ -50,47 +38,103 @@ def get_security_headers() -> dict:
@router.get(
"/summary",
+ responses={
+ 400: {"model": ErrorResponse},
+ 422: {"model": ErrorResponse},
+ 500: {"model": ErrorResponse},
+ },
response_model=SummaryResponse,
+)
+def summary(query: Annotated[SummaryParams, Query()]) -> SummaryResponse:
+ """Fetch metrics summary."""
+
+ try:
+ params = {
+ "start_date": query.start_date,
+ "end_date": query.end_date,
+ "country_code": query.country_code,
+ }
+
+ summary_data = get_summary(params)
+
+ response_data = {
+ "summary": {
+ "total_signup_users": summary_data["total_signup_users"],
+ "total_retained_users": summary_data["total_retained_users"],
+ }
+ }
+
+ return JSONResponse(content=response_data, headers=get_security_headers())
+ except requests.HTTPError as e:
+ raise HTTPException(
+ status_code=e.response.status_code, detail=e.response.json()
+ ) from e
+
+
+@router.get(
+ "/signup",
responses={
400: {"model": ErrorResponse},
422: {"model": ErrorResponse},
+ 500: {"model": ErrorResponse},
},
+ response_model=SignupResponse,
)
-def summary(
- start_date: str,
- end_date: str,
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=10, ge=10),
-):
- """
- Fetch metrics summary.
+def signup(query: Annotated[MetricsParams, Query()]) -> SignupResponse:
+ """Fetch signup users metrics."""
- Args:
- page (int): Pagination page number.
- page_size (int): Number of items per page.
- start_date (datetime): Start date for filtering.
- end_date (datetime): End date for filtering.
+ try:
+ params = {
+ "start_date": query.start_date,
+ "end_date": query.end_date,
+ "country_code": query.country_code,
+ "granularity": query.granularity,
+ "group_by": query.group_by,
+ "top": query.top,
+ "page": query.page,
+ "page_size": query.page_size,
+ }
- Returns:
- dict: Summary of metrics.
- """
- start_date = parse_date(start_date, default_days=0)
- end_date = parse_date(end_date, default_days=30)
+ signup_data = get_signup(params)
+
+ response_data = {"signup": signup_data}
+
+ return JSONResponse(content=response_data, headers=get_security_headers())
+ except requests.HTTPError as e:
+ raise HTTPException(
+ status_code=e.response.status_code, detail=e.response.json()
+ ) from e
+
+
+@router.get(
+ "/retained",
+ responses={
+ 400: {"model": ErrorResponse},
+ 422: {"model": ErrorResponse},
+ 500: {"model": ErrorResponse},
+ },
+ response_model=RetainedResponse,
+)
+def retained(query: Annotated[MetricsParams, Query()]) -> RetainedResponse:
+ """Fetch retained users metrics."""
try:
params = {
- "page": page,
- "page_size": page_size,
- "start": start_date.strftime("%Y-%m-%d"),
- "end": end_date.strftime("%Y-%m-%d"),
+ "start_date": query.start_date,
+ "end_date": query.end_date,
+ "country_code": query.country_code,
+ "granularity": query.granularity,
+ "group_by": query.group_by,
+ "top": query.top,
+ "page": query.page,
+ "page_size": query.page_size,
}
- summary_data = get_summary(params)
+ retained_data = get_retained(params)
+
+ response_data = {"retained": retained_data}
- return JSONResponse(
- content=summary_data,
- headers=get_security_headers(),
- )
+ return JSONResponse(content=response_data, headers=get_security_headers())
except requests.HTTPError as e:
raise HTTPException(
status_code=e.response.status_code, detail=e.response.json()
diff --git a/data_retriever.py b/data_retriever.py
index e7620db..162a5f6 100644
--- a/data_retriever.py
+++ b/data_retriever.py
@@ -28,8 +28,8 @@ def get_summary(params: dict):
Raises:
HTTPError: If any of the external API calls fail.
"""
- retained_metrics_url = f"{VAULT_URL}/v3/retained-user-metrics"
- signup_metrics_url = f"{VAULT_URL}/v3/signup-metrics"
+ retained_metrics_url = f"{VAULT_URL}/v3/metrics/retained"
+ signup_metrics_url = f"{VAULT_URL}/v3/metrics/signup"
try:
retained_response = requests.get(
@@ -43,46 +43,74 @@ def get_summary(params: dict):
retained_metrics = retained_response.json()
signup_metrics = signup_response.json()
- combined_metrics = {
- "summary": {
- "total_signup_users": signup_metrics["total_signup_count"],
- "total_active_users": retained_metrics["total_retained_user_count"],
- "total_signup_countries": signup_metrics["total_country_count"],
- "total_active_countries": retained_metrics["total_country_count"],
- "data": [],
- }
+ metrics_summary = {
+ "total_signup_users": signup_metrics["total_signup_users"],
+ "total_retained_users": retained_metrics["total_retained_users"],
}
- all_dates = set(signup_metrics["data"].keys()).union(
- retained_metrics["data"].keys()
- )
+ return metrics_summary
+
+ except requests.RequestException as e:
+ raise e
+
+
+def get_signup(params: dict):
+ """
+ Fetches signup metrics data from the metrics API.
+
+ Args:
+ params (dict): Query parameters to include in the API request. Expected keys may include:
+ - start_date (str): Start date for the metrics in 'YYYY-MM-DD' format.
+ - end_date (str): End date for the metrics in 'YYYY-MM-DD' format.
+ - granularity (str, optional): Level of detail, e.g., 'day' or 'month'.
+ - group_by (str, optional): Dimension to group the metrics by, e.g., 'country'.
+ - top (int, optional): Maximum number of results to return.
+ - page (int, optional): Pagination page number.
+ - page_size (int, optional): Number of records per page.
- sorted_dates = sorted(all_dates, reverse=True)
+ Returns:
+ dict: The JSON response from the metrics API containing signup data.
+ """
+ signup_metrics_url = f"{VAULT_URL}/v3/metrics/signup"
- for date in sorted_dates:
- combined_date_data = {"date": date, "stats": []}
+ try:
+ signup_response = requests.get(signup_metrics_url, params=params, timeout=30)
- signup_countries = signup_metrics["data"].get(date, {})
- retained_countries = retained_metrics["data"].get(date, {})
- all_countries = set(signup_countries.keys()).union(
- retained_countries.keys()
- )
+ signup_response.raise_for_status()
- for country in all_countries:
- stats = {
- "country": country,
- "signup_users": signup_countries.get(country, {}).get(
- "signup_count", 0
- ),
- "active_users": retained_countries.get(country, {}).get(
- "retained_user_count", 0
- ),
- }
- combined_date_data["stats"].append(stats)
+ return signup_response.json()
- combined_metrics["summary"]["data"].append(combined_date_data)
+ except requests.RequestException as e:
+ raise e
+
+
+def get_retained(params: dict):
+ """
+ Fetches retained metrics data from the metrics API.
+
+ Args:
+ params (dict): Query parameters to include in the API request. Expected keys may include:
+ - start_date (str): Start date for the metrics in 'YYYY-MM-DD' format.
+ - end_date (str): End date for the metrics in 'YYYY-MM-DD' format.
+ - granularity (str, optional): Level of detail, e.g., 'day' or 'month'.
+ - group_by (str, optional): Dimension to group the metrics by, e.g., 'country'.
+ - top (int, optional): Maximum number of results to return.
+ - page (int, optional): Pagination page number.
+ - page_size (int, optional): Number of records per page.
+
+ Returns:
+ dict: The JSON response from the metrics API containing retained data.
+ """
+ retained_metrics_url = f"{VAULT_URL}/v3/metrics/retained"
+
+ try:
+ retained_response = requests.get(
+ retained_metrics_url, params=params, timeout=30
+ )
+
+ retained_response.raise_for_status()
- return combined_metrics
+ return retained_response.json()
except requests.RequestException as e:
raise e
diff --git a/response_models.py b/response_models.py
deleted file mode 100644
index 4435a16..0000000
--- a/response_models.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""
-This program is free software: you can redistribute it under the terms
-of the GNU General Public License, v. 3.0. If a copy of the GNU General
-Public License was not distributed with this file, see .
-"""
-
-from pydantic import BaseModel
-from typing import List, Dict
-
-
-class Stats(BaseModel):
- country: str
- signup_users: int
- active_users: int
-
-
-class SummaryData(BaseModel):
- date: str
- stats: List[Stats]
-
-
-class Overview(BaseModel):
- total_signup_users: int
- total_active_users: int
- total_signup_countries: int
- total_active_countries: int
-
-
-class SummaryResponse(BaseModel):
- summary: Overview
- data: List[SummaryData]
-
-
-class ErrorResponse(BaseModel):
- error: str