Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
2 changes: 0 additions & 2 deletions .env

This file was deleted.

8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copia este archivo a `.env` y completa los valores reales.
# El `.env` y el JSON del service account NO se versionan (ver .gitignore).

# Ruta al archivo JSON del service account de Google con permiso de lectura en GA4.
SERVICE_ACCOUNT_FILE=/ruta/a/tu/service-account.json

# ID de la propiedad de Google Analytics 4 (Admin → Property Settings).
GA4_PROPERTY_ID=000000000
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: ["**"]
pull_request:

jobs:
test:
name: Compile + Unit tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip

- name: Install test tooling
run: |
python -m pip install --upgrade pip
pip install pytest

- name: Syntax check (compile all sources)
run: python -m compileall -q src main.py

- name: Run unit tests
run: pytest
28 changes: 26 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
### json ###
*.json
# Entornos virtuales (¡estaban commiteados!)
venv/
.venv/
env/
bin/
lib/
include/
share/
pyvenv.cfg

# Variables de entorno y credenciales
.env
*.json
!*.example.json

# Salida de reportes
backup/

# Cachés de Python
__pycache__/
*.py[cod]

# macOS / editores
.DS_Store
.idea/
.vscode/
114 changes: 114 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,115 @@
# 📈 GA4 Reports — Factory Method

Generador de reportes de **Google Analytics 4** (Data API v1beta) que exporta
métricas diarias a **CSV**. Usa el patrón de diseño **Factory Method** para
construir distintos tipos de reporte, y una arquitectura por capas
(**domain · usecase · view · infrastructure**) que aísla la lógica de la API de Google.

![CI](https://github.com/AlejandroVegaFullstackDev/GA4_Reports_FactoryMethod/actions/workflows/ci.yml/badge.svg)
![Python](https://img.shields.io/badge/python-3.11-blue)
![GA4](https://img.shields.io/badge/GA4-Data%20API%20v1beta-E37400)
![License](https://img.shields.io/badge/license-MIT-green)

---

## 🏭 Patrón Factory Method

Cada tipo de reporte es un "producto" que sabe construir su propia query; la
factory resuelve la clase concreta a partir de un identificador:

```python
report = GA4ReportFactory.create_report("activeUserspPerDay", "2023-07-01", "2023-07-01")
```

Tipos registrados:

| `report_type` | Dimensiones | Métricas |
|---------------|-------------|----------|
| `activeUserspPerDay` | `date` | `activeUsers` |
| `users` | `date` | `totalUsers`, `newUsers` |
| `page_views` | `date`, `pagePath` | `screenPageViews` |

Agregar un reporte nuevo = crear una clase `GA4Report` y registrarla en la factory.

---

## 🧱 Arquitectura

```
src/
├── domain/
│ ├── ga4_report_domain.py # Factory Method + productos (GA4Report)
│ └── ga4_report_exceptions.py # GA4Exception (error de dominio)
├── usecase/
│ └── ga4_report_generate_usecase.py # Arma la query y transforma la respuesta
├── view/
│ ├── ga4_report_controller.py # Coordina el caso de uso
│ └── ga4_report_dto.py # GA4ReportDTO (especificación del reporte)
└── infraestructure/
└── ga4_report_conection.py # Cliente de la GA4 Data API (service account)
main.py # Recorre fechas y exporta los reportes a CSV
```

---

## 🚀 Uso

### 1. Instala dependencias

```bash
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
```

### 2. Configura el acceso

```bash
cp .env.example .env
# edita .env:
# SERVICE_ACCOUNT_FILE = ruta al JSON del service account (con acceso a GA4)
# GA4_PROPERTY_ID = id de tu propiedad GA4
```

El service account necesita el scope `analytics.readonly` y estar añadido como
usuario de la propiedad de GA4.

### 3. Ejecuta

```bash
python main.py
```

Ajusta `START_DATE`, `END_DATE` y `REPORT_TYPES` en `main.py`. Los CSV se guardan
en `backup/report_<fecha>/<tipo>/`.

---

## 🧪 Tests

```bash
pip install pytest
pytest
```

Cubren la factory (cada tipo construye la query correcta y los tipos inválidos
fallan) y el caso de uso (transformación de la respuesta de GA4 con una conexión
simulada), sin necesidad de credenciales ni de red. Corren en **GitHub Actions**.

---

## 🔐 Seguridad

`.env` y el JSON del service account están en `.gitignore` (sólo se versiona
`.env.example`). El entorno virtual y la salida `backup/` tampoco se versionan.

---

## 🛠️ Stack

`Python 3.11` · `google-analytics-data` · `pandas` · `python-dotenv`

---

## 📄 Licencia

MIT
111 changes: 50 additions & 61 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,55 @@
"""Punto de entrada: genera reportes diarios de GA4 y los exporta a CSV.

Recorre un rango de fechas y, por cada día, ejecuta los reportes indicados en
REPORT_TYPES (resueltos por el Factory Method) y los guarda en `backup/`.

Requiere un `.env` con SERVICE_ACCOUNT_FILE y GA4_PROPERTY_ID (ver `.env.example`).
"""
import os
from datetime import datetime, timedelta

import pandas as pd

from src.domain.ga4_report_domain import GA4ReportFactory
from src.infraestructure.ga4_report_conection import GA4Connection
from src.view.ga4_report_controller import GA4Controller
from src.usecase.ga4_report_generate_usecase import ExecuteGA4ReportUseCase
from src.domain.ga4_report_domain import GA4ReportFactory
from datetime import datetime, timedelta
from src.view.ga4_report_controller import GA4Controller

START_DATE = "2023-07-01"
END_DATE = "2023-12-01"
BACKUP_FOLDER = "backup"
REPORT_TYPES = ["activeUserspPerDay"] # añade "users" o "page_views" según necesites


def save_report(controller: GA4Controller, report_type: str, date_str: str, folder: str) -> None:
report = GA4ReportFactory.create_report(report_type, date_str, date_str)
result = controller.query_report(report)

df = pd.DataFrame(result['rows'], columns=result['headers'])
out_dir = os.path.join(folder, report_type)
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f'{report_type}_report_{date_str}.csv')
df.to_csv(out_path, index=False)
print(f"{report_type} {date_str} -> {out_path}")


def main() -> None:
start_date = datetime.strptime(START_DATE, "%Y-%m-%d")
end_date = datetime.strptime(END_DATE, "%Y-%m-%d")
os.makedirs(BACKUP_FOLDER, exist_ok=True)

controller = GA4Controller(ExecuteGA4ReportUseCase(GA4Connection()))

current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
report_folder = os.path.join(BACKUP_FOLDER, f'report_{date_str}')

for report_type in REPORT_TYPES:
save_report(controller, report_type, date_str, report_folder)

current_date += timedelta(days=1)


start_date_str = "2023-07-01"
end_date_str = "2023-12-01"

start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
end_date = datetime.strptime(end_date_str, "%Y-%m-%d")

backup_folder = "backup"
os.makedirs(backup_folder, exist_ok=True)

ga4_connection = GA4Connection()
execute_use_case = ExecuteGA4ReportUseCase(ga4_connection)
ga4_controller = GA4Controller(execute_use_case)

current_date = start_date
while current_date <= end_date:
current_date_str = current_date.strftime("%Y-%m-%d")

report_folder = os.path.join(backup_folder, f'report_{current_date_str}')
os.makedirs(report_folder, exist_ok=True)

# users_report = GA4ReportFactory.create_report(
# report_type="users",
# start_date=current_date_str,
# end_date=current_date_str
# )
# users_result = ga4_controller.query_report(users_report)
# users_df = pd.DataFrame(users_result['rows'], columns=users_result['headers'])
# users_folder = os.path.join(report_folder, 'usuarios')
# os.makedirs(users_folder, exist_ok=True)
# users_df.to_csv(os.path.join(users_folder, f'users_report_{current_date_str}.csv'), index=False)
# print(f"Users report for {current_date_str} saved to '{users_folder}/users_report_{current_date_str}.csv'")

# page_views_report = GA4ReportFactory.create_report(
# report_type="page_views",
# start_date=current_date_str,
# end_date=current_date_str
# )
# page_views_result = ga4_controller.query_report(page_views_report)
# page_views_df = pd.DataFrame(page_views_result['rows'], columns=page_views_result['headers'])
# page_views_folder = os.path.join(report_folder, 'page_views')
# os.makedirs(page_views_folder, exist_ok=True)
# page_views_df.to_csv(os.path.join(page_views_folder, f'page_views_report_{current_date_str}.csv'), index=False)
# print(f"Page Views report for {current_date_str} saved to '{page_views_folder}/page_views_report_{current_date_str}.csv'")

activeUserspPerDayReport = ga4_controller.query_report(
GA4ReportFactory.create_report(
report_type="activeUserspPerDay",
start_date=current_date_str,
end_date=current_date_str
)
)
activeUserspPerDay_df = pd.DataFrame(activeUserspPerDayReport['rows'], columns=activeUserspPerDayReport['headers'])
activeUserspPerDay_folder = os.path.join(report_folder, 'activeUserspPerDay')
os.makedirs(activeUserspPerDay_folder, exist_ok=True)
activeUserspPerDay_df.to_csv(os.path.join(activeUserspPerDay_folder, f'activeUserspPerDay_report_{current_date_str}.csv'), index=False)
print(f"activeUserspPerDay report for {current_date_str} saved to '{activeUserspPerDay_folder}/activeUserspPerDay_report_{current_date_str}.csv'")

current_date += timedelta(days=1)
if __name__ == '__main__':
main()
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
pythonpath = .
testpaths = tests
addopts = -ra
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
google-analytics-data
# Dependencias directas del proyecto.
google-analytics-data>=0.18 # cliente de la GA4 Data API (v1beta)
pandas>=2.0 # construcción/exportación de los reportes a CSV
python-dotenv>=1.0 # carga de variables desde .env
Empty file removed src/dao/ga4_report_dao.py
Empty file.
Binary file not shown.
64 changes: 47 additions & 17 deletions src/domain/ga4_report_domain.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,58 @@
from abc import ABC, abstractmethod
from src.view.ga4_report_dto import GA4RerportDTO
from abc import ABC
from src.view.ga4_report_dto import GA4ReportDTO


class GA4Report(ABC):
@abstractmethod
def create_query_report(self, *args, **kwargs):
pass

class activeUserspPerDayReport(GA4Report):
def create_query_report(self, start_date, end_date):
return GA4RerportDTO(
report_type="activeUserspPerDay",
dimensions=["date",],
metrics=["activeUsers"],
"""Producto abstracto del Factory Method: cada reporte sabe construir su query."""

report_type: str
dimensions: list
metrics: list

def create_query_report(self, start_date, end_date) -> GA4ReportDTO:
return GA4ReportDTO(
report_type=self.report_type,
dimensions=self.dimensions,
metrics=self.metrics,
row_limit=1000000000,
quota_usage=False,
realtime=False,
start_date=start_date,
end_date=end_date,
)


class ActiveUsersPerDayReport(GA4Report):
report_type = "activeUserspPerDay"
dimensions = ["date"]
metrics = ["activeUsers"]


class UsersReport(GA4Report):
report_type = "users"
dimensions = ["date"]
metrics = ["totalUsers", "newUsers"]


class PageViewsReport(GA4Report):
report_type = "page_views"
dimensions = ["date", "pagePath"]
metrics = ["screenPageViews"]


class GA4ReportFactory:
"""Factory Method: resuelve el reporte concreto a partir de su tipo."""

_registry = {
"activeUserspPerDay": ActiveUsersPerDayReport,
"users": UsersReport,
"page_views": PageViewsReport,
}

@staticmethod
def create_report(report_type, *args, **kwargs):
if report_type == "activeUserspPerDay":
return activeUserspPerDayReport().create_query_report(*args, **kwargs)
else:
raise ValueError("Invalid report type")
def create_report(report_type, start_date, end_date) -> GA4ReportDTO:
report_cls = GA4ReportFactory._registry.get(report_type)
if report_cls is None:
valid = ", ".join(GA4ReportFactory._registry)
raise ValueError(f"Invalid report type '{report_type}'. Válidos: {valid}")
return report_cls().create_query_report(start_date, end_date)
2 changes: 2 additions & 0 deletions src/domain/ga4_report_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class GA4Exception(Exception):
"""Error de dominio para fallos al consultar/generar reportes de GA4."""
Binary file not shown.
Loading
Loading