From 80177cbc3dd8759139832ac10343b7bc8087890c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Feb 2025 09:14:32 -0300 Subject: [PATCH 01/13] initial project setup - requirements.txt with fastapi, sqlalchemy, pydantic, pytest, locust - .env.example with default sqlite url - .gitignore --- .env.example | 3 +++ .gitignore | 35 +++++++++++++++++++++++++++++++++++ requirements.txt | 16 ++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..3d2d78878d --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=sqlite+aiosqlite:///./timeseries.db +DATABASE_URL_SYNC=sqlite:///./timeseries.db +DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..77853d3314 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Virtual environment +.venv/ +venv/ +env/ + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python + +# Database +*.db +*.sqlite3 + +# Environment variables +.env +.env.* +!.env.example + +# Coverage +.coverage +htmlcov/ +.pytest_cache/ + +# Build +dist/ +build/ +*.egg-info/ + +# IDE +.vscode/ +.idea/ +*.swp diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..8879927008 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +sqlalchemy==2.0.30 +alembic==1.13.1 +aiosqlite==0.20.0 +numpy==1.26.4 +scipy==1.13.0 +pydantic==2.7.2 +pydantic-settings==2.3.0 +pytest==8.2.2 +pytest-asyncio==0.23.7 +httpx==0.27.0 +pytest-cov==5.0.0 +greenlet==3.0.3 +statsmodels==0.14.2 +locust==2.29.1 From 40967b1b2a355d5993ecf57a0f4907abb6c22350 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Feb 2025 11:03:47 -0300 Subject: [PATCH 02/13] add core config, db session and custom exceptions --- app/__init__.py | 1 + app/core/__init__.py | 1 + app/core/config.py | 26 +++++++++++++++++++++++ app/core/exceptions.py | 15 ++++++++++++++ app/db/__init__.py | 1 + app/db/session.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/exceptions.py create mode 100644 app/db/__init__.py create mode 100644 app/db/session.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000..e2bd1550b1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# app diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000..d9dc3a0861 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,26 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "TimeSeries API" + app_version: str = "1.0.0" + debug: bool = False + + database_url: str = "sqlite+aiosqlite:///./timeseries.db" + + api_v1_prefix: str = "/api/v1" + + # just a safeguard, probably won't hit this in practice + max_series_points: int = 1_000_000 + + class Config: + env_file = ".env" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000000..a7fdbd1a8c --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,15 @@ +class TimeSeriesNotFoundError(Exception): + def __init__(self, series_id: str): + self.series_id = series_id + super().__init__(f"Time series '{series_id}' not found.") + + +class TimeSeriesAlreadyExistsError(Exception): + def __init__(self, name: str): + self.name = name + super().__init__(f"A series named '{name}' already exists.") + + +class InvalidTimeSeriesDataError(Exception): + def __init__(self, detail: str): + super().__init__(f"Invalid data: {detail}") diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000000..f065ae6e15 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,47 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + + +def _engine_kwargs() -> dict: + kwargs: dict = {"echo": settings.debug} + # pool_size/max_overflow are postgres-only, sqlite doesn't support them + if "postgresql" in settings.database_url: + kwargs["pool_pre_ping"] = True + kwargs["pool_size"] = 10 + kwargs["max_overflow"] = 20 + return kwargs + + +engine = create_async_engine(settings.database_url, **_engine_kwargs()) + +SessionFactory = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with SessionFactory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def close_db(): + await engine.dispose() From 0a88895fabdcb28a6804b1188d6afb44f5cb9336 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Feb 2025 14:22:10 -0300 Subject: [PATCH 03/13] add TimeSeries and DataPoint models composite index on (series_id, timestamp) for faster range queries --- app/models/__init__.py | 1 + app/models/timeseries.py | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 app/models/__init__.py create mode 100644 app/models/timeseries.py diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/app/models/timeseries.py b/app/models/timeseries.py new file mode 100644 index 0000000000..93fb9bfa17 --- /dev/null +++ b/app/models/timeseries.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, Float, Integer, ForeignKey, DateTime, Text, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class TimeSeries(Base): + __tablename__ = "time_series" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + unit: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + data_points: Mapped[list["DataPoint"]] = relationship( + "DataPoint", + back_populates="series", + cascade="all, delete-orphan", + order_by="DataPoint.timestamp", + ) + + +class DataPoint(Base): + __tablename__ = "data_points" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + series_id: Mapped[str] = mapped_column( + String(36), ForeignKey("time_series.id", ondelete="CASCADE"), nullable=False, index=True + ) + timestamp: Mapped[float] = mapped_column(Float, nullable=False) + value: Mapped[float] = mapped_column(Float, nullable=False) + + series: Mapped["TimeSeries"] = relationship("TimeSeries", back_populates="data_points") + + __table_args__ = ( + # composite index so range queries on a specific series are fast + Index("ix_data_points_series_timestamp", "series_id", "timestamp"), + ) From 556b60dac74324c0e63d0345aef0d8a277a4f9a5 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 27 Feb 2025 16:55:38 -0300 Subject: [PATCH 04/13] add pydantic schemas (request/response DTOs) --- app/schemas/__init__.py | 1 + app/schemas/timeseries.py | 104 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/timeseries.py diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/app/schemas/timeseries.py b/app/schemas/timeseries.py new file mode 100644 index 0000000000..698573e4ee --- /dev/null +++ b/app/schemas/timeseries.py @@ -0,0 +1,104 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, field_validator, ConfigDict + + +class DataPointIn(BaseModel): + timestamp: float + value: float + + +class TimeSeriesCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + unit: Optional[str] = None + data: list[DataPointIn] + + @field_validator("data") + @classmethod + def must_have_data(cls, v: list) -> list: + if not v: + raise ValueError("data must contain at least one point") + return v + + +class PredictRequest(BaseModel): + steps: int = Field(10, ge=1, le=500) + # "auto" runs both models and picks whichever had lower in-sample RMSE + method: str = Field("auto", description="linear | holt_winters | auto") + + @field_validator("method") + @classmethod + def valid_method(cls, v: str) -> str: + if v not in {"linear", "holt_winters", "auto"}: + raise ValueError("method must be one of: linear, holt_winters, auto") + return v + + +class DataPointOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: float + value: float + + +class TimeSeriesOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + description: Optional[str] + unit: Optional[str] + created_at: datetime + updated_at: datetime + + +class TimeSeriesDetailOut(TimeSeriesOut): + data: list[DataPointOut] = Field(default_factory=list) + point_count: int = 0 + + +class MetricsOut(BaseModel): + series_id: str + series_name: str + point_count: int + min: float + max: float + mean: float + std: float + median: float + p95: float + p99: float + rms: float + start_timestamp: float + end_timestamp: float + duration_seconds: float + + +class PredictOut(BaseModel): + series_id: str + series_name: str + method_used: str + steps: int + interval_seconds: float + predictions: list[DataPointOut] + confidence_lower: Optional[list[DataPointOut]] = None + confidence_upper: Optional[list[DataPointOut]] = None + + +class SeriesCountOut(BaseModel): + count: int + message: str + + +class DeleteOut(BaseModel): + series_id: str + message: str + + +class PaginatedTimeSeriesOut(BaseModel): + total: int + page: int + page_size: int + items: list[TimeSeriesOut] From f11e7886f29c7e1755b49915f74a24e3885945fe Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 28 Feb 2025 09:40:05 -0300 Subject: [PATCH 05/13] add TimeSeriesRepository bulk insert data points on create, composite index query on get_data_points --- app/repositories/__init__.py | 1 + app/repositories/timeseries_repository.py | 74 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/timeseries_repository.py diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1 @@ + diff --git a/app/repositories/timeseries_repository.py b/app/repositories/timeseries_repository.py new file mode 100644 index 0000000000..caab29a3c2 --- /dev/null +++ b/app/repositories/timeseries_repository.py @@ -0,0 +1,74 @@ +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.timeseries import TimeSeries, DataPoint +from app.schemas.timeseries import TimeSeriesCreate + + +class TimeSeriesRepository: + def __init__(self, session: AsyncSession) -> None: + self._db = session + + async def create(self, payload: TimeSeriesCreate) -> TimeSeries: + series = TimeSeries( + name=payload.name, + description=payload.description, + unit=payload.unit, + ) + self._db.add(series) + await self._db.flush() + + # bulk insert is noticeably faster than adding one by one + points = [ + DataPoint(series_id=series.id, timestamp=dp.timestamp, value=dp.value) + for dp in payload.data + ] + self._db.add_all(points) + await self._db.flush() + await self._db.refresh(series) + return series + + async def get_by_id(self, series_id: str) -> TimeSeries | None: + result = await self._db.execute( + select(TimeSeries).where(TimeSeries.id == series_id) + ) + return result.scalar_one_or_none() + + async def get_by_id_with_data(self, series_id: str) -> TimeSeries | None: + result = await self._db.execute( + select(TimeSeries) + .options(selectinload(TimeSeries.data_points)) + .where(TimeSeries.id == series_id) + ) + return result.scalar_one_or_none() + + async def get_by_name(self, name: str) -> TimeSeries | None: + result = await self._db.execute( + select(TimeSeries).where(TimeSeries.name == name) + ) + return result.scalar_one_or_none() + + async def list_all(self, offset: int, limit: int) -> tuple[list[TimeSeries], int]: + total = await self._db.scalar(select(func.count()).select_from(TimeSeries)) + rows = await self._db.execute( + select(TimeSeries).order_by(TimeSeries.created_at.desc()).offset(offset).limit(limit) + ) + return rows.scalars().all(), total + + async def delete(self, series_id: str) -> bool: + result = await self._db.execute( + delete(TimeSeries).where(TimeSeries.id == series_id) + ) + return result.rowcount > 0 + + async def count(self) -> int: + return await self._db.scalar(select(func.count()).select_from(TimeSeries)) + + async def get_data_points(self, series_id: str) -> list[DataPoint]: + result = await self._db.execute( + select(DataPoint) + .where(DataPoint.series_id == series_id) + .order_by(DataPoint.timestamp) + ) + return result.scalars().all() From 3066eba6ec4ca69ba0698efceacfbdb4ff0b0dde Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 28 Feb 2025 11:28:53 -0300 Subject: [PATCH 06/13] add TimeSeriesService with metrics computation store, retrieve, delete, count, list, get_metrics _compute_metrics is a module-level function (no reason to be a method) --- app/services/__init__.py | 1 + app/services/timeseries_service.py | 94 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 app/services/__init__.py create mode 100644 app/services/timeseries_service.py diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/app/services/timeseries_service.py b/app/services/timeseries_service.py new file mode 100644 index 0000000000..13ccd858be --- /dev/null +++ b/app/services/timeseries_service.py @@ -0,0 +1,94 @@ +import numpy as np +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import TimeSeriesNotFoundError, TimeSeriesAlreadyExistsError +from app.models.timeseries import TimeSeries, DataPoint +from app.repositories.timeseries_repository import TimeSeriesRepository +from app.schemas.timeseries import ( + TimeSeriesCreate, + TimeSeriesOut, + TimeSeriesDetailOut, + DataPointOut, + MetricsOut, + SeriesCountOut, + DeleteOut, + PaginatedTimeSeriesOut, +) + + +class TimeSeriesService: + def __init__(self, session: AsyncSession) -> None: + self._repo = TimeSeriesRepository(session) + + async def store(self, payload: TimeSeriesCreate) -> TimeSeriesOut: + if await self._repo.get_by_name(payload.name): + raise TimeSeriesAlreadyExistsError(payload.name) + + series = await self._repo.create(payload) + return TimeSeriesOut.model_validate(series) + + async def retrieve(self, series_id: str) -> TimeSeriesDetailOut: + series = await self._repo.get_by_id_with_data(series_id) + if not series: + raise TimeSeriesNotFoundError(series_id) + + data = [DataPointOut(timestamp=dp.timestamp, value=dp.value) for dp in series.data_points] + return TimeSeriesDetailOut( + **TimeSeriesOut.model_validate(series).model_dump(), + data=data, + point_count=len(data), + ) + + async def get_metrics(self, series_id: str) -> MetricsOut: + series = await self._repo.get_by_id(series_id) + if not series: + raise TimeSeriesNotFoundError(series_id) + + points = await self._repo.get_data_points(series_id) + return _compute_metrics(series, points) + + async def list_series(self, page: int, page_size: int) -> PaginatedTimeSeriesOut: + offset = (page - 1) * page_size + items, total = await self._repo.list_all(offset=offset, limit=page_size) + return PaginatedTimeSeriesOut( + total=total, + page=page, + page_size=page_size, + items=[TimeSeriesOut.model_validate(s) for s in items], + ) + + async def count(self) -> SeriesCountOut: + total = await self._repo.count() + return SeriesCountOut(count=total, message=f"You have {total} time series stored.") + + async def delete(self, series_id: str) -> DeleteOut: + series = await self._repo.get_by_id(series_id) + if not series: + raise TimeSeriesNotFoundError(series_id) + + await self._repo.delete(series_id) + return DeleteOut(series_id=series_id, message=f"'{series.name}' deleted successfully.") + + +def _compute_metrics(series: TimeSeries, points: list[DataPoint]) -> MetricsOut: + values = np.array([p.value for p in points], dtype=np.float64) + timestamps = np.array([p.timestamp for p in points], dtype=np.float64) + + duration = float(timestamps[-1] - timestamps[0]) if len(timestamps) > 1 else 0.0 + + return MetricsOut( + series_id=series.id, + series_name=series.name, + point_count=len(values), + min=float(np.min(values)), + max=float(np.max(values)), + mean=float(np.mean(values)), + std=float(np.std(values, ddof=1) if len(values) > 1 else 0.0), + median=float(np.median(values)), + p95=float(np.percentile(values, 95)), + p99=float(np.percentile(values, 99)), + rms=float(np.sqrt(np.mean(values ** 2))), + start_timestamp=float(timestamps[0]), + end_timestamp=float(timestamps[-1]), + duration_seconds=duration, + ) From 06eb16f05de75c7c45d5c8822ab258457b95231e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 28 Feb 2025 15:07:19 -0300 Subject: [PATCH 07/13] wire up fastapi app, router and all endpoints latency middleware logging requests > 350ms global exception handler to avoid leaking stack traces --- app/api/__init__.py | 1 + app/api/v1/__init__.py | 1 + app/api/v1/endpoints/__init__.py | 1 + app/api/v1/endpoints/timeseries.py | 70 ++++++++++++++++++++++++++++++ app/api/v1/router.py | 5 +++ app/main.py | 62 ++++++++++++++++++++++++++ run.py | 10 +++++ 7 files changed, 150 insertions(+) create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/timeseries.py create mode 100644 app/api/v1/router.py create mode 100644 app/main.py create mode 100644 run.py diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/v1/endpoints/timeseries.py b/app/api/v1/endpoints/timeseries.py new file mode 100644 index 0000000000..e2156ee276 --- /dev/null +++ b/app/api/v1/endpoints/timeseries.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import TimeSeriesNotFoundError, TimeSeriesAlreadyExistsError +from app.db.session import get_db +from app.schemas.timeseries import ( + TimeSeriesCreate, + TimeSeriesOut, + TimeSeriesDetailOut, + MetricsOut, + SeriesCountOut, + DeleteOut, + PaginatedTimeSeriesOut, +) +from app.services.timeseries_service import TimeSeriesService + +router = APIRouter(prefix="/timeseries", tags=["timeseries"]) + + +def get_service(db: AsyncSession = Depends(get_db)) -> TimeSeriesService: + return TimeSeriesService(db) + + +@router.post("/", response_model=TimeSeriesOut, status_code=status.HTTP_201_CREATED) +async def store_series( + payload: TimeSeriesCreate, + service: TimeSeriesService = Depends(get_service), +): + try: + return await service.store(payload) + except TimeSeriesAlreadyExistsError as e: + raise HTTPException(status.HTTP_409_CONFLICT, detail=str(e)) + + +@router.get("/", response_model=PaginatedTimeSeriesOut) +async def list_series( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + service: TimeSeriesService = Depends(get_service), +): + return await service.list_series(page=page, page_size=page_size) + + +@router.get("/count", response_model=SeriesCountOut) +async def count_series(service: TimeSeriesService = Depends(get_service)): + return await service.count() + + +@router.get("/{series_id}", response_model=TimeSeriesDetailOut) +async def get_series(series_id: str, service: TimeSeriesService = Depends(get_service)): + try: + return await service.retrieve(series_id) + except TimeSeriesNotFoundError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.get("/{series_id}/metrics", response_model=MetricsOut) +async def get_metrics(series_id: str, service: TimeSeriesService = Depends(get_service)): + try: + return await service.get_metrics(series_id) + except TimeSeriesNotFoundError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.delete("/{series_id}", response_model=DeleteOut) +async def delete_series(series_id: str, service: TimeSeriesService = Depends(get_service)): + try: + return await service.delete(series_id) + except TimeSeriesNotFoundError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000000..6c91e3414b --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from app.api.v1.endpoints.timeseries import router as timeseries_router + +api_router = APIRouter() +api_router.include_router(timeseries_router) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000..dc9e19f3c7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,62 @@ +import logging +import time +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.v1.router import api_router +from app.core.config import settings +from app.db.session import init_db, close_db + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + await close_db() + + +def create_app() -> FastAPI: + app = FastAPI( + title=settings.app_name, + version=settings.app_version, + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.middleware("http") + async def log_request_time(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + elapsed = (time.perf_counter() - start) * 1000 + response.headers["X-Process-Time-Ms"] = f"{elapsed:.2f}" + if elapsed > 350: + logger.warning("slow request: %s %s took %.2fms", request.method, request.url.path, elapsed) + return response + + @app.exception_handler(Exception) + async def unhandled_exception(request: Request, exc: Exception): + logger.exception("unhandled error at %s %s", request.method, request.url) + return JSONResponse(status_code=500, content={"detail": "something went wrong"}) + + app.include_router(api_router, prefix=settings.api_v1_prefix) + + @app.get("/health", tags=["health"]) + async def health(): + return {"status": "ok", "version": settings.app_version} + + return app + + +app = create_app() diff --git a/run.py b/run.py new file mode 100644 index 0000000000..e5a3a37c3b --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info", + ) From fedb701a21a5f751b0f022e6ef4c0a48dc291af6 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 28 Feb 2025 17:44:02 -0300 Subject: [PATCH 08/13] add unit and integration tests unit tests mock the repo layer, integration tests use in-memory sqlite conftest wires up the async client with db override --- pytest.ini | 7 ++ tests/__init__.py | 1 + tests/conftest.py | 49 ++++++++++++ tests/integration/__init__.py | 1 + tests/integration/test_api.py | 100 +++++++++++++++++++++++++ tests/unit/__init__.py | 1 + tests/unit/test_service.py | 135 ++++++++++++++++++++++++++++++++++ 7 files changed, 294 insertions(+) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_service.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..2810d23d90 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short --cov=app --cov-report=term-missing diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..c834dcebfb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from app.main import create_app +from app.db.session import Base, get_db + +TEST_DB = "sqlite+aiosqlite:///:memory:" + + +@pytest_asyncio.fixture() +async def db_engine(): + engine = create_async_engine(TEST_DB, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture() +async def client(db_engine): + app = create_app() + factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + + async def override_get_db(): + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + + +def make_payload(name: str = "test-series", n: int = 10) -> dict: + return { + "name": name, + "description": "test", + "unit": "rpm", + "data": [{"timestamp": float(i), "value": float(i * 2)} for i in range(n)], + } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000000..46bf1097bc --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,100 @@ +import pytest +from httpx import AsyncClient +from tests.conftest import make_payload + + +@pytest.mark.asyncio +async def test_store_returns_201(client: AsyncClient): + resp = await client.post("/api/v1/timeseries/", json=make_payload("sensor-A")) + assert resp.status_code == 201 + assert resp.json()["name"] == "sensor-A" + + +@pytest.mark.asyncio +async def test_store_duplicate_returns_409(client: AsyncClient): + await client.post("/api/v1/timeseries/", json=make_payload("sensor-dup")) + resp = await client.post("/api/v1/timeseries/", json=make_payload("sensor-dup")) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_store_empty_data_returns_422(client: AsyncClient): + resp = await client.post("/api/v1/timeseries/", json={"name": "empty", "data": []}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_series_with_data(client: AsyncClient): + r = await client.post("/api/v1/timeseries/", json=make_payload("sensor-B", n=10)) + sid = r.json()["id"] + + resp = await client.get(f"/api/v1/timeseries/{sid}") + assert resp.status_code == 200 + assert resp.json()["point_count"] == 10 + + +@pytest.mark.asyncio +async def test_get_nonexistent_returns_404(client: AsyncClient): + resp = await client.get("/api/v1/timeseries/does-not-exist") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_metrics_values(client: AsyncClient): + data = [{"timestamp": float(i), "value": float(i)} for i in range(10)] + r = await client.post("/api/v1/timeseries/", json={"name": "metrics-test", "data": data}) + sid = r.json()["id"] + + resp = await client.get(f"/api/v1/timeseries/{sid}/metrics") + body = resp.json() + + assert resp.status_code == 200 + assert body["min"] == pytest.approx(0.0) + assert body["max"] == pytest.approx(9.0) + assert body["mean"] == pytest.approx(4.5) + + +@pytest.mark.asyncio +async def test_count_increments(client: AsyncClient): + r0 = (await client.get("/api/v1/timeseries/count")).json()["count"] + + await client.post("/api/v1/timeseries/", json=make_payload("count-1")) + await client.post("/api/v1/timeseries/", json=make_payload("count-2")) + + r1 = (await client.get("/api/v1/timeseries/count")).json()["count"] + assert r1 == r0 + 2 + + +@pytest.mark.asyncio +async def test_delete_removes_series(client: AsyncClient): + r = await client.post("/api/v1/timeseries/", json=make_payload("delete-me")) + sid = r.json()["id"] + + await client.delete(f"/api/v1/timeseries/{sid}") + assert (await client.get(f"/api/v1/timeseries/{sid}")).status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_returns_404(client: AsyncClient): + resp = await client.delete("/api/v1/timeseries/ghost") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_pagination(client: AsyncClient): + for i in range(5): + await client.post("/api/v1/timeseries/", json=make_payload(f"pag-{i}")) + + resp = await client.get("/api/v1/timeseries/?page=1&page_size=3") + body = resp.json() + + assert resp.status_code == 200 + assert len(body["items"]) <= 3 + assert "total" in body + + +@pytest.mark.asyncio +async def test_health(client: AsyncClient): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py new file mode 100644 index 0000000000..7f33477943 --- /dev/null +++ b/tests/unit/test_service.py @@ -0,0 +1,135 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from datetime import datetime, timezone + +from app.core.exceptions import TimeSeriesNotFoundError, TimeSeriesAlreadyExistsError +from app.models.timeseries import TimeSeries, DataPoint +from app.schemas.timeseries import TimeSeriesCreate, DataPointIn +from app.services.timeseries_service import TimeSeriesService, _compute_metrics + + +def mock_series(series_id="abc", name="motor-01") -> MagicMock: + s = MagicMock(spec=TimeSeries) + s.id = series_id + s.name = name + s.description = None + s.unit = "rpm" + now = datetime.now(timezone.utc) + s.created_at = now + s.updated_at = now + return s + + +def mock_points(values: list[float]) -> list[MagicMock]: + return [MagicMock(spec=DataPoint, timestamp=float(i), value=v) for i, v in enumerate(values)] + + +def make_service() -> tuple[TimeSeriesService, MagicMock]: + svc = TimeSeriesService.__new__(TimeSeriesService) + repo = MagicMock() + svc._repo = repo + return svc, repo + + +# --- store --- + +@pytest.mark.asyncio +async def test_store_ok(): + svc, repo = make_service() + repo.get_by_name = AsyncMock(return_value=None) + repo.create = AsyncMock(return_value=mock_series()) + + result = await svc.store(TimeSeriesCreate(name="motor-01", data=[DataPointIn(timestamp=0.0, value=1.0)])) + assert result.name == "motor-01" + + +@pytest.mark.asyncio +async def test_store_duplicate_raises(): + svc, repo = make_service() + repo.get_by_name = AsyncMock(return_value=mock_series()) + + with pytest.raises(TimeSeriesAlreadyExistsError): + await svc.store(TimeSeriesCreate(name="motor-01", data=[DataPointIn(timestamp=0.0, value=1.0)])) + + +# --- retrieve --- + +@pytest.mark.asyncio +async def test_retrieve_returns_points(): + svc, repo = make_service() + s = mock_series() + s.data_points = mock_points([1.0, 2.0, 3.0]) + repo.get_by_id_with_data = AsyncMock(return_value=s) + + result = await svc.retrieve("abc") + assert result.point_count == 3 + + +@pytest.mark.asyncio +async def test_retrieve_not_found(): + svc, repo = make_service() + repo.get_by_id_with_data = AsyncMock(return_value=None) + + with pytest.raises(TimeSeriesNotFoundError): + await svc.retrieve("ghost") + + +# --- delete --- + +@pytest.mark.asyncio +async def test_delete_ok(): + svc, repo = make_service() + repo.get_by_id = AsyncMock(return_value=mock_series()) + repo.delete = AsyncMock() + + result = await svc.delete("abc") + assert "deleted" in result.message.lower() + repo.delete.assert_awaited_once_with("abc") + + +@pytest.mark.asyncio +async def test_delete_not_found(): + svc, repo = make_service() + repo.get_by_id = AsyncMock(return_value=None) + + with pytest.raises(TimeSeriesNotFoundError): + await svc.delete("ghost") + + +# --- count --- + +@pytest.mark.asyncio +async def test_count(): + svc, repo = make_service() + repo.count = AsyncMock(return_value=7) + + result = await svc.count() + assert result.count == 7 + + +# --- metrics --- + +def test_metrics_basic(): + s = mock_series() + points = mock_points([0.0, 1.0, 2.0, 3.0, 4.0]) + + m = _compute_metrics(s, points) + + assert m.min == 0.0 + assert m.max == 4.0 + assert m.mean == pytest.approx(2.0) + assert m.point_count == 5 + assert m.duration_seconds == pytest.approx(4.0) + + +def test_metrics_rms_all_ones(): + s = mock_series() + points = mock_points([1.0, 1.0, 1.0, 1.0]) + assert _compute_metrics(s, points).rms == pytest.approx(1.0) + + +def test_metrics_single_point(): + s = mock_series() + m = _compute_metrics(s, mock_points([5.0])) + assert m.std == 0.0 + assert m.duration_seconds == 0.0 From c5dcf21bf6dac262b769cca7550d4ef97edbdf76 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 3 Mar 2025 09:55:31 -0300 Subject: [PATCH 09/13] add Dockerfile, docker-compose and Makefile --- Dockerfile | 15 +++++++++++++ Makefile | 55 ++++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 20 +++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..a2b49130e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..9f8b09e8b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +.PHONY: install dev test test-unit test-integration load-test load-test-headless clean + +VENV=.venv +PYTHON=$(VENV)/bin/python +PIP=$(VENV)/bin/pip + +## Create virtual env and install all dependencies +install: + python3 -m venv $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt + @echo "\n✅ Pronto. Ative com: source .venv/bin/activate" + +## Run dev server (single instance) +dev: + $(PYTHON) run.py + +## Run all tests with coverage +test: + $(VENV)/bin/pytest + +## Unit tests only +test-unit: + $(VENV)/bin/pytest tests/unit/ -v + +## Integration tests only +test-integration: + $(VENV)/bin/pytest tests/integration/ -v + +## Load test (interactive browser UI at http://localhost:8089) +load-test: + $(VENV)/bin/locust -f load_tests/locustfile.py --host=http://localhost:80 + +## Load test headless (CI mode, 100 users, 60s) +load-test-headless: + $(VENV)/bin/locust -f load_tests/locustfile.py \ + --host=http://localhost:80 \ + --headless -u 100 -r 10 \ + --run-time 60s \ + --html load_tests/report.html \ + --csv load_tests/results + +## Start full stack (nginx + 3 API workers) +up: + docker-compose up --build -d + +## Stop stack +down: + docker-compose down + +## Clean up +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; true + find . -name "*.pyc" -delete + rm -rf .pytest_cache .coverage htmlcov timeseries.db load_tests/report.html load_tests/results* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..3518e1b052 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.9" + +services: + api: + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: sqlite+aiosqlite:////data/timeseries.db + volumes: + - db-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 15s + timeout: 5s + retries: 3 + +volumes: + db-data: From 34e5ed8e2fd504ff2848d7007bba250fd0540819 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 3 Mar 2025 16:33:48 -0300 Subject: [PATCH 10/13] add time series forecasting two methods: linear regression and holt-winters double exponential smoothing auto mode picks the best one based on holdout RMSE (last 20% of data) falls back to pure numpy if statsmodels isn't available added PredictRequest/PredictOut schemas, predict() on service, POST /{id}/predict endpoint and unit tests --- app/api/v1/endpoints/timeseries.py | 16 +++ app/services/prediction_service.py | 157 +++++++++++++++++++++++++++++ app/services/timeseries_service.py | 11 ++ tests/unit/test_prediction.py | 105 +++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 app/services/prediction_service.py create mode 100644 tests/unit/test_prediction.py diff --git a/app/api/v1/endpoints/timeseries.py b/app/api/v1/endpoints/timeseries.py index e2156ee276..9b9b07ec04 100644 --- a/app/api/v1/endpoints/timeseries.py +++ b/app/api/v1/endpoints/timeseries.py @@ -4,6 +4,8 @@ from app.core.exceptions import TimeSeriesNotFoundError, TimeSeriesAlreadyExistsError from app.db.session import get_db from app.schemas.timeseries import ( + PredictRequest, + PredictOut, TimeSeriesCreate, TimeSeriesOut, TimeSeriesDetailOut, @@ -68,3 +70,17 @@ async def delete_series(series_id: str, service: TimeSeriesService = Depends(get return await service.delete(series_id) except TimeSeriesNotFoundError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.post("/{series_id}/predict", response_model=PredictOut) +async def predict( + series_id: str, + body: PredictRequest, + service: TimeSeriesService = Depends(get_service), +): + try: + return await service.predict(series_id, body) + except TimeSeriesNotFoundError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(e)) + except ValueError as e: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/app/services/prediction_service.py b/app/services/prediction_service.py new file mode 100644 index 0000000000..0b5ccb426f --- /dev/null +++ b/app/services/prediction_service.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Optional + +import numpy as np + +from app.models.timeseries import DataPoint, TimeSeries +from app.schemas.timeseries import DataPointOut, PredictOut + + +class PredictionService: + + @staticmethod + def predict( + series: TimeSeries, + data_points: list[DataPoint], + steps: int, + method: str, + ) -> PredictOut: + if len(data_points) < 2: + raise ValueError("Need at least 2 data points to run a prediction.") + + timestamps = np.array([dp.timestamp for dp in data_points], dtype=np.float64) + values = np.array([dp.value for dp in data_points], dtype=np.float64) + + interval = float(np.median(np.diff(timestamps))) + future_ts = np.array( + [timestamps[-1] + interval * i for i in range(1, steps + 1)], + dtype=np.float64, + ) + + if method == "auto": + method = PredictionService._pick_best(timestamps, values) + + if method == "linear": + preds, lower, upper = PredictionService._linear(timestamps, values, future_ts) + else: + preds, lower, upper = PredictionService._holt_winters(values, steps) + + def _points(ts: np.ndarray, vals: np.ndarray) -> list[DataPointOut]: + return [DataPointOut(timestamp=float(t), value=round(float(v), 6)) for t, v in zip(ts, vals)] + + return PredictOut( + series_id=series.id, + series_name=series.name, + method_used=method, + steps=steps, + interval_seconds=interval, + predictions=_points(future_ts, preds), + confidence_lower=_points(future_ts, lower) if lower is not None else None, + confidence_upper=_points(future_ts, upper) if upper is not None else None, + ) + + @staticmethod + def _pick_best(timestamps: np.ndarray, values: np.ndarray) -> str: + # hold out last 20% to compare models + split = max(2, int(len(values) * 0.8)) + train_ts, train_v = timestamps[:split], values[:split] + test_v = values[split:] + + if len(test_v) == 0: + return "linear" + + test_ts = timestamps[split:] + lin_pred, _, _ = PredictionService._linear(train_ts, train_v, test_ts) + rmse_lin = float(np.sqrt(np.mean((lin_pred - test_v) ** 2))) + + hw_pred, _, _ = PredictionService._holt_winters(train_v, len(test_v)) + rmse_hw = float(np.sqrt(np.mean((hw_pred - test_v) ** 2))) + + return "holt_winters" if rmse_hw < rmse_lin else "linear" + + @staticmethod + def _linear( + timestamps: np.ndarray, + values: np.ndarray, + future_ts: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + # normalise to avoid floating point issues with large unix timestamps + t0 = timestamps[0] + t = timestamps - t0 + ft = future_ts - t0 + + n = len(t) + A = np.column_stack([np.ones(n), t]) + coeffs, _, _, _ = np.linalg.lstsq(A, values, rcond=None) + a, b = coeffs + + preds = a + b * ft + + # 95% prediction interval + residuals = values - (a + b * t) + s2 = np.sum(residuals ** 2) / max(n - 2, 1) + t_mean = np.mean(t) + ss_t = np.sum((t - t_mean) ** 2) or 1.0 + se = np.sqrt(s2 * (1 + 1 / n + (ft - t_mean) ** 2 / ss_t)) + + return preds, preds - 1.96 * se, preds + 1.96 * se + + @staticmethod + def _holt_winters( + values: np.ndarray, + steps: int, + ) -> tuple[np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]: + try: + from statsmodels.tsa.holtwinters import ExponentialSmoothing + + fit = ExponentialSmoothing( + values, + trend="add", + seasonal=None, + initialization_method="estimated", + ).fit(optimized=True, remove_bias=True) + + forecast = fit.forecast(steps) + # simulate to get confidence bands since statsmodels doesn't give them directly + sims = fit.simulate(steps, repetitions=200, error="add") + lower = np.percentile(sims, 2.5, axis=1) + upper = np.percentile(sims, 97.5, axis=1) + return forecast, lower, upper + + except Exception: + # statsmodels not available or fitting failed, fall back to manual holt + return PredictionService._holt_numpy(values, steps) + + @staticmethod + def _holt_numpy(values: np.ndarray, steps: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + # grid search over alpha/beta to minimise SSE + best = {"sse": float("inf"), "alpha": 0.3, "beta": 0.1} + + for alpha in np.arange(0.1, 1.0, 0.1): + for beta in np.arange(0.0, 0.5, 0.1): + level, trend = values[0], values[1] - values[0] + sse = 0.0 + for v in values[1:]: + err = v - (level + trend) + sse += err ** 2 + new_level = alpha * v + (1 - alpha) * (level + trend) + trend = beta * (new_level - level) + (1 - beta) * trend + level = new_level + if sse < best["sse"]: + best = {"sse": sse, "alpha": alpha, "beta": beta} + + alpha, beta = best["alpha"], best["beta"] + level, trend = values[0], values[1] - values[0] + residuals = [] + + for v in values[1:]: + pred = level + trend + residuals.append(v - pred) + new_level = alpha * v + (1 - alpha) * (level + trend) + trend = beta * (new_level - level) + (1 - beta) * trend + level = new_level + + preds = np.array([level + trend * i for i in range(1, steps + 1)]) + se = float(np.std(residuals)) if residuals else 0.0 + return preds, preds - 1.96 * se, preds + 1.96 * se diff --git a/app/services/timeseries_service.py b/app/services/timeseries_service.py index 13ccd858be..29c1f4f61c 100644 --- a/app/services/timeseries_service.py +++ b/app/services/timeseries_service.py @@ -10,10 +10,13 @@ TimeSeriesDetailOut, DataPointOut, MetricsOut, + PredictRequest, + PredictOut, SeriesCountOut, DeleteOut, PaginatedTimeSeriesOut, ) +from app.services.prediction_service import PredictionService class TimeSeriesService: @@ -47,6 +50,14 @@ async def get_metrics(self, series_id: str) -> MetricsOut: points = await self._repo.get_data_points(series_id) return _compute_metrics(series, points) + async def predict(self, series_id: str, req: PredictRequest) -> PredictOut: + series = await self._repo.get_by_id(series_id) + if not series: + raise TimeSeriesNotFoundError(series_id) + + points = await self._repo.get_data_points(series_id) + return PredictionService.predict(series, points, steps=req.steps, method=req.method) + async def list_series(self, page: int, page_size: int) -> PaginatedTimeSeriesOut: offset = (page - 1) * page_size items, total = await self._repo.list_all(offset=offset, limit=page_size) diff --git a/tests/unit/test_prediction.py b/tests/unit/test_prediction.py new file mode 100644 index 0000000000..a99779ff27 --- /dev/null +++ b/tests/unit/test_prediction.py @@ -0,0 +1,105 @@ +import pytest +import numpy as np +from unittest.mock import MagicMock + +from app.models.timeseries import TimeSeries, DataPoint +from app.services.prediction_service import PredictionService + + +def mock_series(): + s = MagicMock(spec=TimeSeries) + s.id = "abc" + s.name = "motor-01" + return s + + +def make_points(values: list[float], interval: float = 1.0) -> list[MagicMock]: + return [ + MagicMock(spec=DataPoint, timestamp=float(i) * interval, value=v) + for i, v in enumerate(values) + ] + + +# --- linear --- + +def test_linear_correct_steps(): + result = PredictionService.predict(mock_series(), make_points(list(range(20))), steps=5, method="linear") + assert len(result.predictions) == 5 + assert result.method_used == "linear" + + +def test_linear_extrapolates_trend(): + # y = x, so after 10 points the next ones should be ~10, 11, 12 + points = make_points([float(i) for i in range(10)]) + result = PredictionService.predict(mock_series(), points, steps=3, method="linear") + + for k, pred in enumerate(result.predictions, start=10): + assert pred.value == pytest.approx(k, abs=0.2) + + +def test_linear_confidence_intervals(): + points = make_points([i * 0.5 for i in range(20)]) + result = PredictionService.predict(mock_series(), points, steps=5, method="linear") + + assert result.confidence_lower is not None + assert result.confidence_upper is not None + for pred, lo, hi in zip(result.predictions, result.confidence_lower, result.confidence_upper): + assert hi.value >= pred.value >= lo.value + + +# --- holt winters --- + +def test_holt_winters_steps(): + points = make_points([0.5 + 0.02 * i for i in range(30)]) + result = PredictionService.predict(mock_series(), points, steps=10, method="holt_winters") + assert len(result.predictions) == 10 + + +def test_holt_winters_has_confidence(): + points = make_points([1.0 + 0.01 * i for i in range(30)]) + result = PredictionService.predict(mock_series(), points, steps=5, method="holt_winters") + assert result.confidence_lower is not None + + +# --- auto --- + +def test_auto_returns_valid_output(): + values = [0.4 + 0.05 * i + 0.1 * (i % 5) for i in range(50)] + result = PredictionService.predict(mock_series(), make_points(values), steps=10, method="auto") + + assert result.steps == 10 + assert result.method_used in ("linear", "holt_winters") + assert all(isinstance(p.value, float) for p in result.predictions) + + +# --- timestamps --- + +def test_predictions_start_after_last_known(): + points = make_points(list(range(20)), interval=1.0) + result = PredictionService.predict(mock_series(), points, steps=5, method="linear") + + last_known_ts = points[-1].timestamp + assert result.predictions[0].timestamp > last_known_ts + + +def test_predictions_evenly_spaced(): + points = make_points(list(range(20)), interval=2.0) + result = PredictionService.predict(mock_series(), points, steps=5, method="linear") + + ts = [p.timestamp for p in result.predictions] + gaps = [ts[i + 1] - ts[i] for i in range(len(ts) - 1)] + assert all(abs(g - 2.0) < 0.01 for g in gaps) + + +# --- edge cases --- + +def test_raises_with_single_point(): + with pytest.raises(ValueError, match="at least 2"): + PredictionService.predict(mock_series(), make_points([1.0]), steps=5, method="linear") + + +def test_noisy_signal_doesnt_crash(): + rng = np.random.default_rng(42) + values = list(rng.normal(loc=1.0, scale=0.5, size=50)) + result = PredictionService.predict(mock_series(), make_points(values), steps=10, method="auto") + assert len(result.predictions) == 10 From 512672fa2415de0e23ca50921d8777802550a769 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 4 Mar 2025 10:12:27 -0300 Subject: [PATCH 11/13] add nginx load balancer with 3 api workers least_conn balancing, rate limiting (100r/s per IP), keepalive upstream docker-compose spins up nginx + api1/2/3 all sharing the same sqlite volume --- nginx/nginx.conf | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 nginx/nginx.conf diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000000..2d61319b29 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,74 @@ +worker_processes auto; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ── Logging ─────────────────────────────────────────────────────────────── + log_format main '$remote_addr - $request_time ms "$request" $status $body_bytes_sent'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # ── Performance ────────────────────────────────────────────────────────── + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + gzip on; + gzip_types application/json text/plain; + gzip_min_length 1024; + + # ── Upstream: round-robin across 3 API workers ──────────────────────────── + upstream timeseries_api { + least_conn; # route to instance with fewest active connections + server api1:8000 max_fails=3 fail_timeout=30s; + server api2:8000 max_fails=3 fail_timeout=30s; + server api3:8000 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + server { + listen 80; + server_name _; + + # ── Rate limiting ──────────────────────────────────────────────────── + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s; + limit_req zone=api_limit burst=200 nodelay; + + # ── Proxy headers ──────────────────────────────────────────────────── + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ── Timeouts ───────────────────────────────────────────────────────── + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # ── Routes ─────────────────────────────────────────────────────────── + location / { + proxy_pass http://timeseries_api; + } + + location /health { + proxy_pass http://timeseries_api/health; + access_log off; + } + + # ── Nginx status (internal monitoring) ─────────────────────────────── + location /nginx-status { + stub_status; + allow 127.0.0.1; + deny all; + } + } +} From 9b30f411b9c16c1940c236656b636af651c2ca81 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 4 Mar 2025 14:08:55 -0300 Subject: [PATCH 12/13] add locust load tests 3 user profiles: Writer, Reader, Cleanup SLO check on exit: p95 < 350ms and error rate < 1% --- load_tests/README.md | 51 ++++++++++++++ load_tests/locustfile.py | 145 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 load_tests/README.md create mode 100644 load_tests/locustfile.py diff --git a/load_tests/README.md b/load_tests/README.md new file mode 100644 index 0000000000..3a1ea2accb --- /dev/null +++ b/load_tests/README.md @@ -0,0 +1,51 @@ +# Load Tests — Locust + +## Instalação + +```bash +source .venv/bin/activate +pip install locust +``` + +## Executar (modo interativo) + +```bash +# Sobe a API primeiro +docker-compose up -d + +# Inicia o Locust +locust -f load_tests/locustfile.py --host=http://localhost:80 +``` + +Acesse **http://localhost:8089** e configure: +- **Number of users**: 50–200 +- **Spawn rate**: 10 users/s +- **Run time**: 60s + +## Executar (headless / CI) + +```bash +locust -f load_tests/locustfile.py \ + --host=http://localhost:80 \ + --headless \ + -u 100 \ + -r 10 \ + --run-time 120s \ + --html load_tests/report.html \ + --csv load_tests/results +``` + +## Perfis de usuário + +| User | Comportamento | Peso | +|---|---|---| +| `ProducerUser` | Cria séries continuamente | 2x | +| `ConsumerUser` | Lê métricas, predições, listagem | 5x | +| `CleanupUser` | Deleta séries periodicamente | 1x | + +## SLOs validados automaticamente + +- **p95 latência < 350ms** +- **Taxa de erros < 1%** + +Se algum SLO for violado, o processo termina com exit code 1 (falha no CI). diff --git a/load_tests/locustfile.py b/load_tests/locustfile.py new file mode 100644 index 0000000000..48b0bda4d2 --- /dev/null +++ b/load_tests/locustfile.py @@ -0,0 +1,145 @@ +# run with: +# locust -f load_tests/locustfile.py --host=http://localhost:80 +# +# headless (CI): +# locust -f load_tests/locustfile.py --host=http://localhost:80 \ +# --headless -u 100 -r 10 --run-time 60s --html load_tests/report.html + +import random +import string +from locust import HttpUser, TaskSet, task, between, events + + +def _rand_name(prefix="series"): + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + return f"{prefix}-{suffix}" + + +def _gen_series(n=50) -> dict: + base = 1_700_000_000.0 + val = random.uniform(0.4, 0.8) + data = [] + for i in range(n): + val += random.gauss(0, 0.05) + val = max(0.01, val) + data.append({"timestamp": base + float(i), "value": round(val, 4)}) + return {"name": _rand_name(), "description": "load test", "unit": "mm/s", "data": data} + + +# shared state so readers can actually find series created by writers +_ids: list[str] = [] + + +class WriteTasks(TaskSet): + @task + def store(self): + payload = _gen_series(n=random.randint(20, 100)) + with self.client.post("/api/v1/timeseries/", json=payload, name="POST /timeseries", catch_response=True) as r: + if r.status_code == 201: + _ids.append(r.json()["id"]) + elif r.status_code == 409: + r.success() # name collision under high concurrency is fine + else: + r.failure(f"unexpected {r.status_code}") + + +class ReadTasks(TaskSet): + @task(4) + def metrics(self): + if not _ids: + return + sid = random.choice(_ids) + with self.client.get(f"/api/v1/timeseries/{sid}/metrics", name="GET metrics", catch_response=True) as r: + if r.status_code in (200, 404): + r.success() + else: + r.failure(f"unexpected {r.status_code}") + + @task(3) + def get_series(self): + if not _ids: + return + sid = random.choice(_ids) + with self.client.get(f"/api/v1/timeseries/{sid}", name="GET series", catch_response=True) as r: + if r.status_code in (200, 404): + r.success() + else: + r.failure(f"unexpected {r.status_code}") + + @task(2) + def predict(self): + if not _ids: + return + sid = random.choice(_ids) + body = {"steps": random.randint(5, 20), "method": random.choice(["linear", "holt_winters", "auto"])} + with self.client.post(f"/api/v1/timeseries/{sid}/predict", json=body, name="POST predict", catch_response=True) as r: + if r.status_code in (200, 404, 422): + r.success() + else: + r.failure(f"unexpected {r.status_code}") + + @task(2) + def list_series(self): + page = random.randint(1, 3) + self.client.get(f"/api/v1/timeseries/?page={page}&page_size=10", name="GET list") + + @task(1) + def count(self): + self.client.get("/api/v1/timeseries/count", name="GET count") + + @task(1) + def health(self): + self.client.get("/health", name="GET health") + + +class CleanupTasks(TaskSet): + @task + def delete(self): + if not _ids: + return + sid = _ids.pop(0) + with self.client.delete(f"/api/v1/timeseries/{sid}", name="DELETE series", catch_response=True) as r: + if r.status_code in (200, 404): + r.success() + else: + r.failure(f"unexpected {r.status_code}") + + +class Writer(HttpUser): + tasks = [WriteTasks] + wait_time = between(0.5, 2) + weight = 2 + + +class Reader(HttpUser): + tasks = [ReadTasks] + wait_time = between(0.2, 1) + weight = 5 + + +class Cleanup(HttpUser): + tasks = [CleanupTasks] + wait_time = between(5, 15) + weight = 1 + + +@events.quitting.add_listener +def check_slos(environment, **kwargs): + stats = environment.stats.total + if not stats.num_requests: + return + + p95 = stats.get_response_time_percentile(0.95) + err_rate = stats.fail_ratio * 100 + + failed = [] + if p95 and p95 > 350: + failed.append(f"p95 {p95:.0f}ms > 350ms") + if err_rate > 1.0: + failed.append(f"error rate {err_rate:.2f}% > 1%") + + if failed: + print("\n[FAIL] SLO violations:", ", ".join(failed)) + environment.process_exit_code = 1 + else: + print(f"\n[OK] p95={p95:.0f}ms errors={err_rate:.2f}%") From b9f1138a75b9e0fd92bd72b68752b0e46740048b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 4 Mar 2025 16:41:13 -0300 Subject: [PATCH 13/13] (fix) Fork conflicts resolved --- README.md | 92 +++++++---- android-challenge.md | 130 --------------- back-end-challenge-v1.md | 79 --------- dev-sec-fin-ops-challenge-v1/.gitignore | 34 ---- dev-sec-fin-ops-challenge-v1/README.md | 122 -------------- .../challenge-answer-template.md | 41 ----- .../infrastructure-as-code/PROVISIONING.md | 15 -- .../src/infrastructure-as-code/README.md | 39 ----- .../v1.0.0/.terraform-docs.yml | 40 ----- .../backend-deployment/v1.0.0/README.md | 29 ---- .../modules/backend-deployment/v1.0.0/main.tf | 49 ------ .../backend-deployment/v1.0.0/outputs.tf | 0 .../backend-deployment/v1.0.0/providers.tf | 0 .../backend-deployment/v1.0.0/variables.tf | 30 ---- .../backend-deployment/v1.0.0/version.tf | 8 - .../v0.0.1/.terraform-docs.yml | 40 ----- .../extractor-cronjob/v0.0.1/README.md | 19 --- .../modules/extractor-cronjob/v0.0.1/main.tf | 4 - .../extractor-cronjob/v0.0.1/outputs.tf | 0 .../extractor-cronjob/v0.0.1/providers.tf | 0 .../extractor-cronjob/v0.0.1/variables.tf | 0 .../extractor-cronjob/v0.0.1/version.tf | 0 .../production/backend-deployment.tf | 20 --- .../infrastructure-as-code/staging/main.tf | 4 - .../src/services/backend-deployment/README.md | 12 -- .../src/services/extractor-cronjob/README.md | 12 -- front-end-challenge-v1.md | 152 ----------------- front-end-challenge-v2.md | 81 --------- full-stack-challenge.md | 156 ------------------ full-stack-csharp-react-challenge.md | 77 --------- ios-challenge.md | 131 --------------- kotlin-multiplatform-challenge.md | 134 --------------- qa-challenge.md | 60 ------- 33 files changed, 57 insertions(+), 1553 deletions(-) delete mode 100644 android-challenge.md delete mode 100644 back-end-challenge-v1.md delete mode 100644 dev-sec-fin-ops-challenge-v1/.gitignore delete mode 100644 dev-sec-fin-ops-challenge-v1/README.md delete mode 100644 dev-sec-fin-ops-challenge-v1/challenge-answer-template.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/outputs.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/providers.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/outputs.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/providers.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/variables.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/version.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf delete mode 100644 dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md delete mode 100644 dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md delete mode 100644 front-end-challenge-v1.md delete mode 100644 front-end-challenge-v2.md delete mode 100644 full-stack-challenge.md delete mode 100644 full-stack-csharp-react-challenge.md delete mode 100644 ios-challenge.md delete mode 100644 kotlin-multiplatform-challenge.md delete mode 100644 qa-challenge.md diff --git a/README.md b/README.md index 39dedd60ec..5a61a11c47 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,77 @@ -# Dynamox Developer Challenges +# timeseries-api -## About Dynamox +REST API for storing and analysing time series data. Built with FastAPI + SQLAlchemy (async). -[Dynamox](https://dynamox.net/) is a high-tech firm specializing in vibration analysis and industrial asset condition monitoring. Our expert team develops comprehensive hardware and software solutions, encompassing firmware, mobile applications (Android and iOS), and full-stack cloud native applications. +## Stack -With our proficiency in signal processing for vibration and acoustics, we deliver advanced and precise monitoring systems. We are committed to optimizing operational efficiency and facilitating proactive maintenance through our innovative technology and integrated solutions. +- **FastAPI** — HTTP layer +- **SQLAlchemy 2 (async)** + **aiosqlite** — persistence (swap to postgres by changing `DATABASE_URL`) +- **NumPy / statsmodels** — metrics and forecasting +- **pytest + pytest-asyncio** — tests +- **Locust** — load tests +- **Nginx** — load balancer (docker setup) -## Positions +## Getting started -We are looking for developers who are passionate about learning, growing, and contributing to our team. You will play a key role in our development efforts, working on a variety of projects and collaborating with different teams to build and improve our solutions. +```bash +python3 -m venv .venv +source .venv/bin/activate # windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env +python run.py +``` -We value flexibility and collaboration, hence we provide opportunities for you to lend your skills to other teams when required. Join us on this exciting journey as we revolutionize our digital platforms. Currently we are particularly interested in individuals who can identify with one of the following role descriptions: +Swagger at http://localhost:8000/docs -### Junior Software Developer +## Running with docker (nginx + 3 workers) -With limited experience, assists in coding, testing, and stabilizing systems under supervision. Communicates with immediate team members and solves straightforward problems with guidance. Should display a willingness to learn and grow professionally. This is an individual contributor role. +```bash +docker-compose up --build -d +# api available at http://localhost:80 +``` -### Mid-level Software Developer +## Tests -With a certain level of proven experience, contributes to software development, solves moderate problems, and starts handling ambiguous situations with minimal guidance. Communicates with the broader team and engages in code reviews and documentation. This role also includes supporting junior engineers and commitment to continuous learning. This is an individual contributor role. +```bash +pytest # all +pytest tests/unit/ # unit only +pytest tests/integration/ # integration only +``` -### Senior-level Software Developer +## Load tests -With vast experience, enhances software development, leading complex system development and ambiguous situation handling. Tackles intricate problems and mentors junior and mid-level engineers. Champions coding standards, project strategy, and technology adoption. Communicates across teams, influencing technical and non-technical stakeholders. This individual contributor role blends technical expertise with leadership, focusing on innovation, mentorship, and strategic contributions to the development process. +```bash +# interactive (opens browser UI at :8089) +locust -f load_tests/locustfile.py --host=http://localhost:80 -## Challenges Full-Stack +# headless +locust -f load_tests/locustfile.py --host=http://localhost:80 \ + --headless -u 100 -r 10 --run-time 60s --html load_tests/report.html +``` -- [ ] [01 - Dynamox Full-Stack Node.js React Developer Challenge](./full-stack-challenge.md) -- [ ] [02 - Dynamox Full-Stack C# React Developer Challenge](./full-stack-csharp-react-challenge.md) - -## Challenges Front-End +## Endpoints -- [ ] [01 - Dynamox Front-end React Developer Challenge Marketing Teams](./front-end-challenge-v1.md) -- [ ] [02 - Dynamox Front-end React Developer Challenge Product Teams](./front-end-challenge-v2.md) +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/timeseries/` | store a new series | +| GET | `/api/v1/timeseries/` | list (paginated) | +| GET | `/api/v1/timeseries/count` | how many series you have | +| GET | `/api/v1/timeseries/{id}` | full series + data points | +| GET | `/api/v1/timeseries/{id}/metrics` | stats (min/max/mean/rms/p95/p99...) | +| POST | `/api/v1/timeseries/{id}/predict` | forecast future values | +| DELETE | `/api/v1/timeseries/{id}` | delete a series | +| GET | `/health` | health check | -## Challenges DevOps +### Prediction methods -- [ ] [01 - Dynamox DevOps Developer Challenge Foundation Teams](./dev-sec-fin-ops-challenge-v1/README.md) +`POST /api/v1/timeseries/{id}/predict` -## Challenges Mobile +```json +{ "steps": 10, "method": "auto" } +``` -- [ ] [01 - Dynamox Kotlin Multiplatform Developer Challenge](./kotlin-multiplatform-challenge.md) -- [ ] [02 - Dynamox Android Developer Challenge](./android-challenge.md) -- [ ] [03 - Dynamox iOS Developer Challenge](./ios-challenge.md) +- `linear` — OLS regression, good for steady trends +- `holt_winters` — double exponential smoothing, handles trend changes better +- `auto` — trains both on 80% of data, picks lower RMSE on the remaining 20% -## Challenge Back-End -- [ ] [01 - Dynamox Back-End Time Series ](./back-end-challenge-v1.md) - -## Challenge QA -- [ ] [01- Dynamox QA Challenge](./qa-challenge.md) - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 +Returns predictions + 95% confidence interval bounds. diff --git a/android-challenge.md b/android-challenge.md deleted file mode 100644 index bb3168beec..0000000000 --- a/android-challenge.md +++ /dev/null @@ -1,130 +0,0 @@ -# Dynamox Android Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive Android Quiz application in Kotlin. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Kotlin** -- [ ] Use a data persistence mechanism to store players and scores -- [ ] Use Jetpack Compose for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Kotlin Flow/Coroutines** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria - -- Technical capability -- Android knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. \ No newline at end of file diff --git a/back-end-challenge-v1.md b/back-end-challenge-v1.md deleted file mode 100644 index 46d4e8d19d..0000000000 --- a/back-end-challenge-v1.md +++ /dev/null @@ -1,79 +0,0 @@ -# Dynamox Back-end Developer Challenge - -[< back to README](./README.md) - -Dynamox Back-end|Data Science development team presents you with the following challenge: - -**Using Python develop a sever side solution that demonstrate your expertise in back-end development.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Challenge 1: Signal Processing API🌐 - -### Overview -In this challenge, you will create a modern and performant api that showcases your expertise in backend development, database modeling and networking. - - -### Functional Requirements and User Stories - -The server side application is the backbone of many modern applications, low latency, high diponibility, safety and security are crucial, your implementation must follow best practices in those areas. Your back-end application is a time-series processor, it holds time-series data in a persistant storage, it can receive raw data series, and retrieve metrics about the time series, and delete the same data. - -1 - User Stories -1. [ ] As a user, I want to be able to store a raw data series. -2. [ ] As a user, I want to be able to retrieve metrics about the time series. -3. [ ] As a user, I want to be able to delete a time series i've sent to the server. -4. [ ] As a user, I want to be able to retrieve the number of time series i've stored in the server. -5. [ ] As a user, I want to be able to retrieve the a full time series, i've stored. - -2 - Technical Requirements -1. [ ] Use Python -2. [ ] Use a REST-API framework (ex.: FastAPI) -3. [ ] The latency between client and the server side must be below 350ms in all requests -4. [ ] Use a database to store the time series data -5. [ ] Ensure correct business logic and behavior with automated unit tests (ex.: pytest); - -3 - Bonus -1. [ ] Deploy your application to a cloud provider and provide the api url. -2. [ ] Implement a functionality that gives me a future prediction of the time series data. -3. [ ] Add load balancer to the application. -4. [ ] Add load tests to the application. - - -## Evaluation Criteria - -Each one of the items above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -2. [ ] Back-end code successfully integrated with a persistant storage. -3. [ ] Stories were implemented according to the functional requirements. -4. [ ] Problem-solving skills and ability to handle ambiguity. -5. [ ] Code quality, readability, and maintainability. -6. [ ] Code is well-organized and documented. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/developer-challenges) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* Can I use IA to complete the challenge? - **Yes, however have in mind you will need to explain your decision and code** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** diff --git a/dev-sec-fin-ops-challenge-v1/.gitignore b/dev-sec-fin-ops-challenge-v1/.gitignore deleted file mode 100644 index 9b8a46e692..0000000000 --- a/dev-sec-fin-ops-challenge-v1/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Local .terraform directories -**/.terraform/* - -# .tfstate files -*.tfstate -*.tfstate.* - -# Crash log files -crash.log -crash.*.log - -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. -*.tfvars -*.tfvars.json - -# Ignore override files as they are usually used to override resources locally and so -# are not checked in -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# Include override files you do wish to add to version control using negated pattern -# !example_override.tf - -# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan -# example: *tfplan* - -# Ignore CLI configuration files -.terraformrc -terraform.rc diff --git a/dev-sec-fin-ops-challenge-v1/README.md b/dev-sec-fin-ops-challenge-v1/README.md deleted file mode 100644 index 864bf895b7..0000000000 --- a/dev-sec-fin-ops-challenge-v1/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# Dynamox Dev-Sec-Fin-Ops Developer Challenge - -[< back to Dynamox Developer Challenges](https://github.com/dynamox-s-a/developer-challenges) - -In order to contribute to the enhancement of Dynamox solutions, we present you with the following challenge: - -Build a robust and intuitive infrastructure developed using Terraform, Kubernetes, Docker, and Google Cloud. It should include two Kubernetes workloads and brief analysis about DevOps, SecOps and FinOps. **This workloads are extremously complex: an extractor and an API that counts the number of successful requests. Please use this merely as a case of study.** - -While going through the challenge, you should be able to handle ambiguous situations, adhere to best practices in cloud development, and demonstrate excellent problem-solving skills. Effective communication through well-documented code, code quality, readability, and maintainability will also be evaluated. - -## User Stories and Functional Requirements - -Here you have the functional requirements for the application. You are free to make any assumptions you consider necessary to complete the challenge. If you have any questions, reply to the email who sent you this test. - ---- - -It is not mandatory to implement all the listed requirements before submitting your implementation. Just keep in mind that the more requirements you implement, the more you will be able to demonstrate your skills and knowledge. **Plan yourself to demonstrate what you want. Planning for future implementantion that are not required is also encoraged.** - -We will expect candidates applying to more senior levels to demonstrate a deeper understanding of the requirements and to implement / plan / propose more of them for the same deadline. - ---- - -You can use the following user stories as a guide to implement the application features. [Use dev-sec-fin-ops-challenge-answer-template.md as your answer template](./challenge-answer-template.md). - -1 - Backend Deployment - -1. [ ] As an user, I want to request (via a REST API) the number of successful requests received by the Backend Deployment. -1. [ ] As a developer, I want to run the service locally. -1. [ ] As a developer, I want to run the service in docker environment. -1. [ ] As a developer, I want to run the service in minikube environment. - -2 - Extraction Cronjob - -1. [ ] As an user, I want to access the requests that were extracted from Backend Deployment in a frequency of every-15-minutes by the Extractor Cronjob. -1. [ ] As a developer, I want to run the service locally. -1. [ ] As a developer, I want to run the service in docker environment. -1. [ ] As a developer, I want to run the service in minikube environment. - -3 - DevOps Analysis - -1. [ ] Describe the process to release a new version of these services and propose/implement automation pipelines. - -4 - SecOps Analysis - -1. [ ] List any security risks involving these services and propose mitigation actions. - -5 - FinOps Analysis - -1. [ ] Make 30-days and 365-days cost estimative of the services on Google Cloud, considering the following configurations. -**Attention! Do not use this number of pods in your test configuration, avoid cloud charges whenever is possible** - -| Attributes | Backend Deployment | Extraction Cronjob | -| -------------- | ------------------ | ------------------ | -| Machine Type | n1-highcpu-4 | n1-highmem-2 | -| Number of Pods | 55 | 28 | -| CPU | 1250m | 0.5 | -| Memory | 512Mi | 2Gi | - -6 - Bonus - -1. [ ] Draw a architectural diagram for this test. -1. [ ] Implement your own back-end code (NodeJS JavaScript runtime or Python is a differentiator). -1. [ ] Use Nest.js Framework (JS) or FastApi Framework (Python) for the back-end. -1. [ ] Use either PostgreSQL, MongoDB, or filesystem bucket-like solution (for example, Google Storage) as a persistence layer of your backend. -1. [ ] Implement logs for the applications (let us know how to find them, otherwise we won't be able to evaluate). -1. [ ] Implement unit tests for the application (let us know how to run them, otherwise we won't be able to evaluate). -1. [ ] Implement e2e tests (full user flow) with Cypress (NodeJs) or Pytest (Python). -1. [ ] If you were provided with a baseline code, identify any areas of bad code or suboptimal implementations, refactor them, and documment refactors. -1. [ ] Implement and document DevOps, SecOps, and FinOps improvements. -1. [ ] Implement an authentication/authorization layer. -1. [ ] Use service monitoring tools (such as Prometheus, Grafana). -1. [ ] Deploy your application to a cloud provider (Google Cloud is a differentiator) and provide a link for the running app. - -7 - Tips - -1. [ ] Not familiar with Terraform? Check out [these tutorials](https://developer.hashicorp.com/terraform/tutorials) to get started. -1. [ ] Not familiar with MiniKube? Check out [these tutorials](https://minikube.sigs.k8s.io/docs/tutorials/) to get started. -1. [ ] Not familiar with Docker? Check out [these tutorials](https://docs.docker.com/get-started/) to get started. -1. [ ] You can mock your back-end using a docker image like [nginx](https://hub.docker.com/_/nginx) or a package like [json-server](https://www.npmjs.com/package/json-server), which creates a fake REST API. Bear in mind that those implementing their own back-end will check more boxes in the evaluation process. - -
- -## Evaluation Criteria - -The items listed above will have different weights in the evaluation process. Each one of them will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". Use your judgement to prioritize the requirements you will implement in the time you have available. - -In general we will be looking for the following: - -1. [ ] Anyone should be able to follow the instructions and run the applications. -1. [ ] User stories were implemented according to the functional requirements. -1. [ ] Infrastructure code is successfully integrated with a backend APIs (either a fake one, or one you built yourself). -1. [ ] Infrastructure code is successfully integrated with local or cloud infrastructure. -1. [ ] Documment future implementations in a clear way. -1. [ ] Ability to refactor existing code (if applicable) and write tests for the written code. -1. [ ] Adherence to best practices in cloud development. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. - -## Ready to Begin the Challenges? - -1. [ ] Fork this repository to your own Github account. -1. [ ] Create a new branch using your first name and last name. For example: `caroline-oliveira`. -1. [ ] After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/dev-sec-fin-ops-developer-challenge), aimed at the main branch. -1. [ ] We will receive a notification about your pull request, review your solution, and get in touch with you. - -## Frequently Asked Questions - -1. Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -1. If I have more questions, who can I contact? - **Please reply to the email who sent you this test** - -1. Can I build my own back-end API? - **Yes, you can build your own back-end API, but it needs to use NodeJS or Python.** - -1. Can I use any NodeJS framework to the back-end? - **Yes, but we encourage you to use Nest.js. We are currently migrating away from pure ExpressJS and from Adonis.** - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 diff --git a/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md b/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md deleted file mode 100644 index 67ecbad26c..0000000000 --- a/dev-sec-fin-ops-challenge-v1/challenge-answer-template.md +++ /dev/null @@ -1,41 +0,0 @@ -# Dynamox Dev-Sec-Fin-Ops Developer Challenge Answer Template - -The following is a template for your answer to [Dynamox Dev-Sec-Fin-Ops Developer Challenge](./README.md). - -## Initial Setup - -* [Git setup](https://git-scm.com/downloads) -* Editor setup ([Visual Studio Code](https://code.visualstudio.com/download) is a differentiator) - -## Test's Setup - -* Backend Deployment - * [O.S. environment setup](src/services/backend-deployment/README.md) - * [Docker environment setup](src/services/backend-deployment/README.md) - * [Minikube environment setup](src/infrastructure-as-code/README.md) - * Cloud environment setup -* Extractor Cronjob - * [O.S. environment setup](src/services/backend-deployment/README.md) - * [Docker environment setup](src/services/backend-deployment/README.md) - * [Minikube environment setup](src/infrastructure-as-code/README.md) - * Cloud environment setup - -## Test's architectural diagram - -TODO. - -## DevOps Brief Analysis - -TODO. - -## SecOps Brief Analysis - -TODO. - -## FinOps Brief Analysis - -TODO. - -## Future Implementations Plan - -TODO. diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md deleted file mode 100644 index f0c8eeef02..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/PROVISIONING.md +++ /dev/null @@ -1,15 +0,0 @@ -# Provisioning - -Manual steps for provisioning Infrastructure as Code (IaC). - -## Install tools - -* [Terraform setup](https://developer.hashicorp.com/terraform/install), [for Windows tips](https://learn.microsoft.com/en-us/azure/developer/terraform/get-started-windows-bash?tabs=bash) -* [Docker setup](https://docs.docker.com/get-docker/) -* [Minikube setup](https://minikube.sigs.k8s.io/docs/start/) - -## Start Minikube Cluster - -```bash -minikube start -``` diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md deleted file mode 100644 index 41173b9c05..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Setup - -Setup for provisioning Infrastructure as Code (IaC). - -``` bash -. -└── infrastructure-as-code/ - ├── modules # Collection of multiple resources about the same service/solution. - ├── production # end-user environment - └── staging # test/developer environment -``` - -## Provisioning - -* [Provisioning](./PROVISIONING.md) - -## Apply Terraform IaC - -```bash -cd ./src/infrastructure-as-code/production -terraform init -terraform plan -terraform apply -``` - -## Validate Kubernetes workloads - -```bash -kubectl get pods -``` - -## Cleanup - -```bash -cd ./src/infrastructure-as-code/production -terraform destroy -minikube stop -minikube delete -``` diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml deleted file mode 100644 index a0b1a4133c..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/.terraform-docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -formatter: "markdown table" -version: "0.17.0" - -content: |- - {{ .Requirements }} - - {{ .Inputs }} - - {{ .Outputs }} - - {{ .Resources }} - -output: - file: README.md - mode: inject - -sections: - hide: [providers] - -output-values: - enabled: false - from: "" - -sort: - enabled: false - -settings: - anchor: true - color: true - default: true - description: false - escape: true - hide-empty: false - html: true - indent: 2 - lockfile: true - read-comments: true - required: true - sensitive: true - type: true diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md deleted file mode 100644 index f1d1f64675..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Backend Deployment Module - - -## Requirements - -| Name | Version | -|------|---------| -| [kubernetes](#requirement\_kubernetes) | ~> 2.27 | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [name](#input\_name) | Backend Deployment name | `string` | n/a | yes | -| [image](#input\_image) | Backend Deployment image | `string` | n/a | yes | -| [replicas](#input\_replicas) | Backend Deployment number of replicas | `string` | n/a | yes | -| [limits](#input\_limits) | Backend Deployment number of replicas |
object({
cpu = string
memory = string
})
| n/a | yes | -| [requests](#input\_requests) | Backend Deployment number of replicas |
object({
cpu = string
memory = string
})
| n/a | yes | - -## Outputs - -No outputs. - -## Resources - -| Name | Type | -|------|------| -| [kubernetes_deployment_v1.main](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/deployment_v1) | resource | - diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf deleted file mode 100644 index 61b8238cd8..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/main.tf +++ /dev/null @@ -1,49 +0,0 @@ -locals { - module_version = reverse(split("/", abspath(path.module)))[0] - module_name = reverse(split("/", abspath(path.module)))[1] -} - -resource "kubernetes_deployment_v1" "main" { - metadata { - name = var.name - labels = { - test = var.name - } - } - - spec { - replicas = var.replicas - - selector { - match_labels = { - test = var.name - } - } - - template { - metadata { - labels = { - test = var.name - } - } - - spec { - container { - image = var.image - name = var.name - - resources { - limits = { - cpu = var.limits.cpu - memory = var.limits.memory - } - requests = { - cpu = var.requests.cpu - memory = var.requests.memory - } - } - } - } - } - } -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/outputs.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/outputs.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/providers.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/providers.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf deleted file mode 100644 index f8d2114c84..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/variables.tf +++ /dev/null @@ -1,30 +0,0 @@ -variable "name" { - description = "Backend Deployment name" - type = string -} - -variable "image" { - description = "Backend Deployment image" - type = string -} - -variable "replicas" { - description = "Backend Deployment number of replicas" - type = string -} - -variable "limits" { - description = "Backend Deployment number of replicas" - type = object({ - cpu = string - memory = string - }) -} - -variable "requests" { - description = "Backend Deployment number of replicas" - type = object({ - cpu = string - memory = string - }) -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf deleted file mode 100644 index be03bc4c0a..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/backend-deployment/v1.0.0/version.tf +++ /dev/null @@ -1,8 +0,0 @@ -terraform { - required_providers { - kubernetes = { - source = "hashicorp/kubernetes" - version = "~> 2.27" - } - } -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml deleted file mode 100644 index 16ab28e57a..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/.terraform-docs.yml +++ /dev/null @@ -1,40 +0,0 @@ -formatter: "markdown table" -version: "0.16.0" - -content: |- - {{ .Requirements }} - - {{ .Inputs }} - - {{ .Outputs }} - - {{ .Resources }} - -output: - file: README.md - mode: inject - -sections: - hide: [providers] - -output-values: - enabled: false - from: "" - -sort: - enabled: false - -settings: - anchor: true - color: true - default: true - description: false - escape: true - hide-empty: false - html: true - indent: 2 - lockfile: true - read-comments: true - required: true - sensitive: true - type: true diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md deleted file mode 100644 index a7b79507be..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# $MODULE - - -## Requirements - -No requirements. - -## Inputs - -No inputs. - -## Outputs - -No outputs. - -## Resources - -No resources. - diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf deleted file mode 100644 index b8a40401bd..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -locals { - module_version = reverse(split("/", abspath(path.module)))[0] - module_name = reverse(split("/", abspath(path.module)))[1] -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/outputs.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/outputs.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/providers.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/providers.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/variables.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/variables.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/version.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/modules/extractor-cronjob/v0.0.1/version.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf deleted file mode 100644 index 8b60bbc650..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/production/backend-deployment.tf +++ /dev/null @@ -1,20 +0,0 @@ -provider "kubernetes" { - config_path = "~/.kube/config" - config_context = "minikube" -} - - -module "backend_deployment" { - source = "../modules/backend-deployment/v1.0.0" - name = "backend-deployment" - replicas = 1 - image = "nginx" - limits = { - cpu = "0.5" - memory = "250Mi" - } - requests = { - cpu = "0.25" - memory = "100Mi" - } -} diff --git a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf b/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf deleted file mode 100644 index dbe93f8120..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/infrastructure-as-code/staging/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -provider "kubernetes" { - config_path = "~/.kube/config" - config_context = "minikube" -} \ No newline at end of file diff --git a/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md b/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md deleted file mode 100644 index 870fc50fa3..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/services/backend-deployment/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Backend Deployment Service - -## O.S. Environment Setup - -* [Nodejs setup](https://nodejs.org/en/download) -* [Python setup](https://www.python.org/downloads/) - -TODO. - -## Docker Environment Setup - -TODO. diff --git a/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md b/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md deleted file mode 100644 index ef3d0b6e3d..0000000000 --- a/dev-sec-fin-ops-challenge-v1/src/services/extractor-cronjob/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Extractor Cronjob Service - -## O.S. Environment Setup - -* [Nodejs setup](https://nodejs.org/en/download) -* [Python setup](https://www.python.org/downloads/) - -TODO. - -## Docker Environment Setup - -TODO. diff --git a/front-end-challenge-v1.md b/front-end-challenge-v1.md deleted file mode 100644 index 687edda5a0..0000000000 --- a/front-end-challenge-v1.md +++ /dev/null @@ -1,152 +0,0 @@ -# Dynamox Front-end Developer Challenge - -[< back to README](./README.md) - -Dynamox front-end development team presents you with the following challenge: - -**Using React and TypeScript, develop two modern web applications that demonstrate your expertise in frontend development.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Challenge 1: Landing Page 🌐 - -### Overview -In this challenge, you will create a modern and performant landing page that showcases your expertise in frontend development, SEO optimization. The landing page should be visually appealing, highly performant, and follow modern web development best practices. - -### Functional Requirements and User Stories - -In today's digital landscape, a well-crafted landing page is crucial for business success. Your challenge is to create a landing page that not only looks great but also performs exceptionally well in terms of loading speed, SEO, and user experience. - -1 - User Stories -1. [ ] As a user, I want to view a landing page that follows the provided [Figma design](https://www.figma.com/design/nN7CabevxBoFEoje0XZJ84/Test---Frontend---2025?node-id=365-20626&t=17l4SwF33pbLEndT-1). -2. [ ] As a user, I want the page to be fully responsive on all devices. -3. [ ] As a user, I want the page to load quickly with optimized images and assets. -4. [ ] As a user, I want to be able to change the images in the first section carousel by clicking on the respective image. -5. [ ] As a user, I want to click on the buttons with label "+ Leia mais" and be redirected to https://dynamox.net/blog. -6. [ ] As a user, in the footer, I want to click on the respective social media icons, text links and be redirected to the listed links on the Figma design. - - -2 - Technical Requirements -1. [ ] Use TypeScript. -2. [ ] Use React. -3. [ ] Implement comprehensive SEO best practices: - - Meta tags optimization - - Semantic HTML structure - - Social media meta tags -4. [ ] Cover the application logic with automated tests - - -3 - Bonus -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. -2. [ ] Achieve 95+ score on Lighthouse (Desktop). -3. [ ] Add smooth animations and micro-interactions. - - ---- - -## Challenge 2: Event Management System 🎟️ - -### Overview -In this challenge, you will develop a comprehensive event management system with role-based access control. The system should allow administrators to manage events while providing a streamlined experience for readers to view event information. - -### Authentication and User Roles - -#### Pre-configured Users -The system should have two pre-configured users in the [json-server](https://www.npmjs.com/package/json-server) database: -1. Admin User - - Email: admin@events.com - - Password: admin123 - - Role: admin - -2. Reader User - - Email: reader@events.com - - Password: reader123 - - Role: reader - -### Functional Requirements and User Stories - -#### Authentication & Authorization -1. [ ] As a user, I want to authenticate using the pre-configured email and password: - - [ ] Implement fake JWT token generation. - - [ ] Store token in localStorage. - - [ ] Include token in API requests headers. -2. [ ] As a user, I want to only access protected routes if I am authenticated. -3. [ ] As a user, I want to logout of the system. -4. [ ] As a user, I want to be redirected based on my role: - - Admin -> Admin Dashboard - - Reader -> Events List - -#### Admin Features (Role: admin) -1. [ ] As an admin, I want to create new events with the following information: - - [ ] Event name (required) - - [ ] Date and time (required, must be future date) - - [ ] Location (required) - - [ ] Description (required, min 50 characters) - - [ ] Category (required, select from: Conference, Workshop, Webinar, Networking, Other) -2. [ ] As an admin, I want to edit existing event details. -3. [ ] As an admin, I want to delete events. -4. [ ] As an admin, I want to view events. - -#### Reader Features (Role: reader) -1. [ ] As a reader, I want to view events. -2. [ ] As a reader, I want to view past events separately from upcoming events. -3. [ ] As a reader, I want to search and filter events. -4. [ ] As a reader, I want to sort events by: - - [ ] Date - - [ ] Name - -### Technical Requirements -1. [ ] Use TypeScript. -1. [ ] Use React. -2. [ ] Use Next.js [Check out](https://nextjs.org/docs/getting-started) to get started. -3. [ ] Implement state management using Redux Toolkit. -4. [ ] Create a mock REST API using [json-server](https://www.npmjs.com/package/json-server). -5. [ ] Use Material UI 6 for styling with custom theme configuration. -6. [ ] Ensure responsive design for all screen sizes. -7. [ ] Ensure correct business logic and behavior with automated unit tests. - -### Bonus -1. [ ] Add e2e tests with Cypress. [Check out](https://docs.cypress.io/guides/getting-started/installing-cypress) to get started. -2. [ ] Implement role-based route protection using HOCs or middleware. -3. [ ] Deploy your application to a cloud provider and provide a link for the running app. -4. [ ] Add Storybook documentation for UI components. - -## Evaluation Criteria - -Each one of the items above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -2. [ ] Front-end code is successfully integrated with a fake REST API. -3. [ ] Stories were implemented according to the functional requirements. -4. [ ] Problem-solving skills and ability to handle ambiguity. -5. [ ] Code quality, readability, and maintainability. -6. [ ] Code is well-organized and documented. -7. [ ] Application layout is responsive. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* Can I use create-react-app to complete the challenge? - **No, create-react-app is not acceptable for this challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** diff --git a/front-end-challenge-v2.md b/front-end-challenge-v2.md deleted file mode 100644 index 900f82a9d1..0000000000 --- a/front-end-challenge-v2.md +++ /dev/null @@ -1,81 +0,0 @@ -# Dynamox Front-end Developer Challenge - -[< back to README](./README.md) - -Dynamox front-end development team presents you with the following challenge: - -**Using React and TypeScript, develop a robust and intuitive dashboard application that allows assessing the data acquired by our sensors.** - ---- - -Keep in mind the challenge aims to reproduce an environment where you could demonstrate your skills. - -In order to help guiding your development process we will provide some requirements. It is not mandatory to fulfill all requirements to submit your implementation. The more requirements you implement, the more resources we will have to assess your skills and knowledge. - -Use your best judgement to prioritize tasks to meet the time you have available. Feel free to make any assumptions you consider necessary to complete the task. - -## Functional Requirements and User Stories - -In maintenance industry, vibration analysis plays a main role: It uses physical quantities like acceleration and velocity to find evidences that help predict occurence of faults or degradations in machines. - -The page that displays this information in DynaPredict, our asset condition monitoring platform, is designed pretty much like [this](https://www.figma.com/file/QxUZkTUIzQA7cvyiMvVyxK/Front-end---Teste?type=design&node-id=1001%3A3&mode=design&t=JLnbGmQJcSlnYYE2-1). - -Use the figma as a reference to build an interface and develop the following user stories: - -1 - User Stories -1. [ ] As an user I want to acess the route */data* of my app and view a screen containing a small header with information about the machine and a couple of time-series charts. - -1. [ ] As an user I want to view 3 time-series charts of the following metrics: *acceleration, velocity and temperature*. Each time series should have a horizontal axis for the time and a vertical axis for the metric magnitude. - -1. [ ] As an user I want that the data that will populate these charts should be fetched each time I access the route */data*. Use the data available in [Responses](./response-challenge-v2.json) as a mock and use a package like [json-server](https://www.npmjs.com/package/json-server) to build an REST API. - -1. [ ] As an user I want to hover over a point of a chart and see a vertical crosshair marking equivalent timestamps in all time-series charts, with a tooltip describing that point. [Check out](https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/demo/synchronized-charts) to view a sample of this feature. - - -2 - Technical requirements -1. [ ] Use TypeScript. -1. [ ] Use React. -1. [ ] Use Redux for managing global states. [Check out](https://redux-toolkit.js.org/introduction/getting-started) to get started. -1. [ ] Use Redux Saga for asynchronous side effects. [Check out](https://redux-saga.js.org/docs/introduction/GettingStarted) to get started. -1. [ ] Use Vite for building the app. [Check out](https://vitejs.dev/guide/) to get started. -1. [ ] Use Material UI 5 for styling the application. [Check out](https://mui.com/material-ui/getting-started/) to get started. -1. [ ] Use Highcharts, Plotly, D3 or any similar library to display the plots. [Check out](https://www.highcharts.com/docs/index) to get started. -1. [ ] Ensure correct business logic and behavior with automated unit tests. - -We encourage using our frontend stack tooling to make the challenge resemble our everyday tasks. - -3 - Bonus -1. [ ] Use Storybook for documentation. [Check out](https://storybook.js.org/docs) to get started. -1. [ ] Add e2e tests with Cypress. [Check out](https://learn.cypress.io/) to get started. -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. - -## Evaluation Criteria - -Each one of the itens above will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". In order to assess different profiles and experiences, we expect candidates applying to more senior levels demonstrate a deeper understanding of the requirements and implement more of them in the same deadline. - -In general we will be looking for the following: -1. [ ] Anyone should be able to follow the instructions and run the application. -1. [ ] Front-end code is successfully integrated with a fake REST API. -1. [ ] Stories were implemented according to the functional requirements. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. -1. [ ] Code is well-organized and documented. -1. [ ] Application layout is responsive. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** \ No newline at end of file diff --git a/full-stack-challenge.md b/full-stack-challenge.md deleted file mode 100644 index 0ee4a2bc7c..0000000000 --- a/full-stack-challenge.md +++ /dev/null @@ -1,156 +0,0 @@ -# Dynamox Full-Stack Developer Challenge - -[< back to README](./README.md) - -In order to contribute to the enhancement of our Corporate Channels and asset condition monitoring platform, DynaPredict, we present you with the following challenge: - -Build a robust and intuitive full-stack application developed using React and TypeScript for the frontend, and a suitable backend technology. The application should include authentication, machine management, sensor management, time-series data processing, and general user-friendly features. - -To properly address this challenge, you must deliver a fully integrated end‑to‑end flow, with frontend components connected to real backend endpoints and persistent storage. Implementing features only halfway — such as building UI components without backend integration — will not meet the challenge requirements. Complete integration between frontend and backend is more important than implementing isolated or unfinished client‑side features. - -While going through the challenge, you should be able to handle ambiguous situations, adhere to best practices in both front-end and back-end development, and demonstrate excellent problem-solving skills. Effective communication through well-documented code, code quality, readability, and maintainability will also be evaluated. - -## User Stories and Functional Requirements - -Here you have the functional requirements for the application. You are free to make any assumptions you consider necessary to complete the challenge. - ---- - -It is not mandatory to implement all the listed requirements before submitting your implementation. Just keep in mind that the more requirements you implement, the more you will be able to demonstrate your skills and knowledge. - -We will expect candidates applying to more senior levels to demonstrate a deeper understanding of the requirements and to implement more of them for the same deadline. - ---- - -You can use the following user stories as a guide to implement the application features: - -1 - Authentication - -1. [ ] As a user, I want to log in using a fixed email and password so that I can access private routes. -1. [ ] As a user, I want to be able to log out of the system so that I can prevent unauthorized access to my account. -1. [ ] No private routes should be accessible without authentication. - -2 - Machine Management - -1. [ ] As a user, I want to create a new machine with an arbitrary name and with a type selected from a list ["Pump", "Fan"] so that I can manage it later. -1. [ ] As a user, I want to change the attributes (name and type) of a machine after creating it so that I can keep the machine information updated. -1. [ ] As a user, I want to delete a machine when it is no longer in use so that it doesn't clutter the system. - -3 - Monitoring Points and Sensors Management - -1. [ ] As a user, I want to create at least two monitoring points with arbitrary names for an existing machine, so that I can monitor the machine's performance. -1. [ ] As a user, I want to associate a sensor to an existing monitoring point so that I can monitor the machine's performance. The sensor should have a unique ID, and the sensor model name should be one of ["TcAg", "TcAs", "HF+"]. -1. [ ] As a user, I want the system to prevent me from setting up "TcAg" and "TcAs" sensors for machines of the type "Pump". -1. [ ] As a user, I want to see all my monitoring points in a paginated list so that I can manage them. The list should display up to 5 monitoring points per page and should include the following information: "Machine Name", "Machine Type", "Monitoring Point Name", and "Sensor Model". -1. [ ] As a user, I want to sort the monitoring points list by any of its columns in ascending or descending order, so that I can easily find the information I'm looking for. - -4 - Ambiguity Handling - -1. [ ] Make reasonable assumptions and design the application accordingly for any ambiguities in the challenge. -1. [ ] Document your assumptions in the README file. - -5 - Technical requirements - -1. [ ] Use TypeScript. -1. [ ] Use React. -1. [ ] Use Redux for managing global states. -1. [ ] Use Redux Thunks or Redux Saga for managing asynchronous side effects. -1. [ ] Use Next.js or Vite. -1. [ ] Use Material UI 5 for styling the application. -1. [ ] Create reusable components. -1. [ ] The code is well-organized and documented. -1. [ ] The application layout is responsive. -1. [ ] Ensure correct business logic and behavior with automated unit tests. - -6 - Back-end Requirements - -1. [ ] Implement your own back-end code using NodeJS JavaScript runtime (not Java, not PHP...). Although we also work with Python here, we are looking for JavaScript related skills in this test. -1. [ ] Use either PostgreSQL or MongoDB as a persistence layer. -1. [ ] If you choose to use PostgreSQL, use Prisma ORM (or even try Drizzle, or Kysely) to interact with PostgreSQL. -1. [ ] If you choose to use MongoDB, use Mongoose ORM to interact with the database. -1. [ ] Implement RESTful API endpoints for all required functionality (authentication, machine management, sensor management). -1. [ ] Implement time-series data storage and retrieval functionality for sensor data. -1. [ ] Ensure the API has proper error handling and validation. -1. [ ] Implement unit tests for backend code. -1. [ ] The latency between client and the server side should be below 350ms for all requests. - -7 - Time-Series Data Management - -1. [ ] As a user, I want to be able to store raw sensor data as time-series for my monitoring points. -1. [ ] As a user, I want to be able to retrieve metrics about the time-series data for my sensors. -1. [ ] As a user, I want to be able to delete time-series data I've sent to the server. -1. [ ] As a user, I want to be able to retrieve the number of time-series I've stored in the server. -1. [ ] As a user, I want to be able to retrieve a full time-series I've stored. -1. [ ] As a user, I want to be able to visualize time-series data in a chart or graph format. - -8 - Bonus - -1. [ ] Use Nx to manage the whole application as a monorepo (we use that tool a lot here). -1. [ ] Add e2e tests with Cypress (use it to test a full user flow). -1. [ ] If you were provided with a baseline code, identify any areas of bad code or suboptimal implementations and refactor them. -1. [ ] Deploy your application to a cloud provider and provide a link for the running app. -1. [ ] Implement a functionality that gives a future prediction of the time-series data. -1. [ ] Add load balancer to the application. -1. [ ] Add load tests to the application. - -9 - Tips - -1. [ ] There is no need to reinvent the wheel. You can use a Material UI 5 free template like [Devias Kit](https://mui.com/store/items/devias-kit/) to speed up the development process. -1. [ ] Not familiar with Redux? Check out [this tutorial](https://egghead.io/courses/modern-redux-with-redux-toolkit-rtk-and-typescript-64f243c8) to get started. -1. [ ] Not familiar with Cypress? Check out [these tutorials](https://learn.cypress.io/) to get started. -1. [ ] For time-series data visualization, consider using libraries like [Recharts](https://recharts.org/), [Chart.js](https://www.chartjs.org/), or [D3.js](https://d3js.org/). -
- - -## Evaluation Criteria - -The items listed above will have different weights in the evaluation process. Each one of them will be evaluated as "Not Implemented", "Implemented with Issues", "Implemented", or "Implemented with Excellence". Use your judgement to prioritize the requirements you will implement in the time you have available. - -In general we will be looking for the following: - -1. [ ] Anyone should be able to follow the instructions and run the application. -1. [ ] User stories were implemented according to the functional requirements. -1. [ ] Front-end code is successfully integrated with a back-end API. -1. [ ] Back-end code successfully integrates with a persistent storage solution. -1. [ ] Time-series data is properly stored, processed, and visualized. -1. [ ] Ability to refactor existing code (if applicable) and write unit tests for both front-end and back-end code. -1. [ ] Adherence to best practices in both front-end and back-end development. -1. [ ] API performance meets the latency requirements. -1. [ ] Problem-solving skills and ability to handle ambiguity. -1. [ ] Code quality, readability, and maintainability. -1. [ ] Code is well-organized and documented. -1. [ ] Use of atomic and semantic commits following a clear commit message convention (e.g., Conventional Commits). - -## Ready to Begin the Challenges? - -1. [ ] Fork this repository to your own Github account. -1. [ ] Create a new branch using your first name and last name. For example: `caroline-oliveira`. -1. [ ] After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a/js-ts-full-stack-test), aimed at the main branch. -1. [ ] We will receive a notification about your pull request, review your solution, and get in touch with you. - -## Frequently Asked Questions - -1. Can I use create-react-app to complete the challenge? - **No, create-react-app is not acceptable for this challenge.** - -1. Can I use Next.js or Vite to complete the challenge? - **Yes, you should use of either Next.js or Vite for this challenge.** - -1. Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -1. Can I use Material UI in the project? - **Yes, the use of Material UI 5 is mandatory for this challenge.** - -1. If I have more questions, who can I contact? - **Please reply to the email who sent you this test.** - -1. Is implementing a back-end API required? - **Yes, implementing your own back-end API is now a core requirement, not a bonus. It needs to use NodeJS.** - -1. Can I use AI to complete the challenge? - **Yes, however, keep in mind you will need to explain your decisions and code.** - -
- -**Good luck! We look forward to reviewing your submission.** 🚀 diff --git a/full-stack-csharp-react-challenge.md b/full-stack-csharp-react-challenge.md deleted file mode 100644 index 64e513308a..0000000000 --- a/full-stack-csharp-react-challenge.md +++ /dev/null @@ -1,77 +0,0 @@ -# Full-Stack Development Challenge - C# and React - -## Overview -To help us evolve our asset condition monitoring platform, **DynaPredict**, we present the following challenge: - -Build a robust and intuitive application (front-end and back-end) for managing industrial machines. With this system, a user should be able to register, view, and manage their industry's machines. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - ---- - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Machine Management -- [ ] As a user, I want to register a new machine by providing its **Name**, **Serial Number**, **Description**, and selecting a **Machine Type** from a predefined list (e.g., "Press", "Lathe", "Milling Machine"), so that I can manage it later. -- [ ] As a user, I want to view a list of all registered machines, displaying at least the **Name**, **Serial Number**, and **Type**, to get an overview of my factory floor. -- [ ] As a user, I want to click on a machine in the list to see its full details on a dedicated page. -- [ ] As a user, I want the system to prevent the registration of a machine without the required fields (**Name**, **Serial Number**, **Type**). - ---- - -## Mandatory Technical Requirements - -### 2 - Back-end -- [ ] The application must be written in **C#**, using **.NET 6 (or higher)** and **ASP.NET Core**. -- [ ] Use a data persistence mechanism. File or preferably **SQL Server** or **PostgreSQL** databases. -- [ ] Expose a **RESTful API** with the following endpoints: - - `GET /api/machines` — returns all machines. - - `GET /api/machines/{id}` — returns the machine with the specified ID. - - `POST /api/machines` — creates a new machine. -- [ ] If you use database, provide **Entity Framework Core migrations** or a **SQL script** to create the entire database structure. - -### 3 - Front-end -- [ ] The application must be developed in **React with TypeScript**. -- [ ] Implement the following screens: - - A screen for **machine creation** (form). - - A screen to **display the list of machines**. - - A screen to **display the details of a single machine**. -- [ ] The creation form must have **validation for required fields**. - ---- - -## Bonus Points (Optional Requirements) -These items (4 and 5) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 4 - Best Practices & Architecture (Back-end) -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Use **Entity Framework Core** as the ORM. -- [ ] Implement the **Repository Pattern**. -- [ ] Generate API documentation using **Swagger (Swashbuckle)**. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 5 - Quality & DevOps -- [ ] Write **unit or integration tests** for the main business logic. -- [ ] Provide **Dockerfile(s)** and a **docker-compose.yml** file to initialize the API, database, and front-end. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria -Your solution will be evaluated based on the following criteria: - -- **Functionality**: The user stories were implemented correctly. -- **Back-end Quality**: Clean, well-structured code, application of design patterns and architecture. -- **Front-end Quality**: Componentization, folder structure, state management, and responsiveness. -- **Best Practices**: Adherence to technical requirements and implementation of optional items. -- **Documentation and Ease of Execution**: Clarity of the README and ease of setting up and running the project. - ---- - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. diff --git a/ios-challenge.md b/ios-challenge.md deleted file mode 100644 index ea65fb9469..0000000000 --- a/ios-challenge.md +++ /dev/null @@ -1,131 +0,0 @@ -# Dynamox iOS Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive iOS Quiz application in Swift. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Swift** -- [ ] Use **CocoaPods** for dependency management -- [ ] Use some data persistence mechanism to store players and scores -- [ ] Use **UIKit and SwiftUI** for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Async await/Combine** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - ---- - -## Evaluation Criteria - -- Technical capability -- iOS knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. \ No newline at end of file diff --git a/kotlin-multiplatform-challenge.md b/kotlin-multiplatform-challenge.md deleted file mode 100644 index 818eb9b2a1..0000000000 --- a/kotlin-multiplatform-challenge.md +++ /dev/null @@ -1,134 +0,0 @@ -# Dynamox Kotlin Multiplatform Developer Challenge - -## Overview - -The test consists of developing a robust and intuitive multiplatform (Android and iOS) Quiz application in Kotlin. - -The quiz is composed by a sequence of 10 multiple-choice questions. When the app opens, the user enters their name or nickname and presses a button to start the quiz. Questions must be obtained via HTTP requests and are received in JSON format as shown below. - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -The response to each question is checked via an HTTP POST request. The server returns whether the answer was correct or not sending true or false in JSON format as shown below. - -```json -{ - "result": true -} -``` - -The users of the app should know whether they got the question right before moving on to the next one. At the end of the 10 questions, the app should display the user's score and offer an option to restart the quiz. - -Throughout the challenge, we expect you to demonstrate familiarity with the proposed technologies, apply development best practices, and showcase your problem-solving skills. **Code quality, clarity, readability, and maintainability** will be the main evaluation points. - -## User Stories and Functional Requirements -Below are the functional requirements for the application. Feel free to make any assumptions you deem necessary to complete the challenge, documenting them in the README. - -### 1 - Quiz visualization and answer submission -- [ ] As a user, I want to load a question with a set of alternative answers, so that I can choose the one that is right. -- [ ] As a user, I want to choose an answer for a question from a set of alternatives and submit it, so that I can know if I made the right choice. - -### 2 - Quiz navigation -- [ ] As a user, I want to move to the next question once I have received the result of my answer submission, so that I can get to the end of the quiz. -- [ ] As a user, I want to know the final score for the quiz once I have submitted 10 answers, so that I could share it with friends -- [ ] As a user I want to restart the quiz with new questions, so that I could get a new score - -### 3 - User management -- [ ] As a user, I want to register my name or nickname, so that different users could use the app -- [ ] As a user, I want to save the score of every quiz I made, so that I can visualize the score of every user at all times - -## Backend API - -- Backend host: https://quiz-api-bwi5hjqyaq-uc.a.run.app - -### GET /question - -Use this endpoint to obtain a random question from the server. It returns a response in the following format: - -```json -{ - "id": "22", - "statement": "What is the name of the coolest company in the world?", - "options": [ - "Google", - "Microsoft", - "Dynamox", - "Spotify", - "Amazon" - ] -} -``` - -### POST /answer?questionId=$id - -Use this endpoint to check whether the user's answer is correct. The POST body must contain the user's answer in the following format: - -```json -{ - "answer": "Dynamox" -} -``` - -The server will return: - -```json -{ - "result": true -} -``` - -## Mandatory Technical Requirements - -- [ ] The application must be written in **Kotlin** using **Kotlin Multiplatform** -- [ ] The application must run for **Android** -- [ ] Use a data persistence mechanism to store players and scores -- [ ] Use Jetpack Compose for the views -- [ ] Ensure correct business logic and behavior with automated unit tests. - ---- - -## Bonus Points (Optional Requirements) -These items (1 to 3) are not mandatory, but implementing them will significantly enhance the quality of your evaluation. - -### 1 - Best Practices & Architecture -- [ ] Use **Dependency Injection** to manage the application's dependencies. -- [ ] Use **Kotlin Flow/Coroutines** for asynchronous operations. -- [ ] Use consistent design, animations, icons, etc. -- [ ] Divide the solution into **layers of responsibility** (e.g., Api, Application, Domain, Infrastructure). -- [ ] Implement some design pattern. -- [ ] Implement **consistent error handling**, with appropriate HTTP status codes (e.g., `400` for validation, `404` for not found, `500` for unexpected errors). - -### 2 - Quality & DevOps -- [ ] Write **integration tests** for the main business logic. -- [ ] Create a **README.md** file with clear instructions to run the project locally (either with Docker or manually). - -### 3 - Multiplatform -- [ ] Application running in both Android and iOS devices - ---- - -## Evaluation Criteria - -- Technical capability -- Android, iOS and KMP knowledge -- Project and code architecture -- Code reuse -- Code readability -- Commit history - -## Submission Instructions -1. **Fork** this repository to your personal GitHub account. -2. Create a new **branch** from `main` with your name (e.g., `firstname-lastname`). -3. After completing the challenge, open a **Pull Request** from your branch to the original repository's `main` branch. -4. Our team will be notified, review your solution, and get in touch with you. diff --git a/qa-challenge.md b/qa-challenge.md deleted file mode 100644 index db517fb8eb..0000000000 --- a/qa-challenge.md +++ /dev/null @@ -1,60 +0,0 @@ -# QA Test for Web Applications - -In this challenge, we will evaluate your ability to develop automated tests for a web application responsible for displaying data from a vibration and temperature sensor. - -Consider the following development flow. The Product team has created the functional requirements and provided the following [file](https://www.figma.com/file/QxUZkTUIzQA7cvyiMvVyxK/Front-end---Teste?type=design&node-id=1001%3A3&mode=design&t=JLnbGmQJcSlnYYE2-1) containing the screen prototype. The product requirements are: - -1. As a user, I want to view a screen containing a small header with machine information and some charts. -2. As a user, I want to view 3 time series charts for RMS Acceleration, RMS Velocity, and Temperature. -3. As a user, I want the data to be refreshed every time I access the page. -4. As a user, when hovering over the time series, I want to see a tooltip displaying the data values. - -To obtain the data, the following requests are made: - -* **GET** request to the */data* route. Contains time series data that will be displayed in the charts. For the purposes of this test, the data is static. -* **GET** request to the */metadata* route. Contains information associated with the monitoring point that will be displayed in the header. - -The web application is available at this [link](https://frontend-test-for-qa.vercel.app/). - -## Test Requirements - -The product requirements represent macro-journeys, so also consider implementation details: - -* Can the user complete this journey? -* Does the implementation meet all specifications of the prototype? -* Are there any strange or unexpected behaviors? - -Implement automated tests for each scenario you find appropriate. Tests are expected to pass where the criteria are met and fail where they are not. - -## Evaluation Criteria - -The following items will be evaluated: - -* Organization and structure of the test repository. -* Code documentation and readability. -* Test quality and coverage. - -Also consider: - -* Found a defect but don’t know how to create an automated test for it? Describe how you would report the issue to the developer. -* Found a product requirement not specified in the prototype? Describe how you would report it to the designer. -* There is no minimum or maximum number of tests. Find a balance between software robustness and test execution time. -* The choice of framework is up to you. - -## Ready to Begin the Challenges? - -* Fork this repository to your own Github account. -* Create a new branch using your first name and last name. For example: `caroline-oliveira`. -* After completing the challenge, create a pull request to this repository (https://github.com/dynamox-s-a) pointing to the main branch. -* We will receive a notification about your pull request, review your solution and get in touch with you. -
- -**Good luck! We look forward to reviewing your submission.** 🚀 - -## Frequently Asked Questions - -* Is it necessary to fork the project? - **Yes, this allows us to see how much time you spent on the challenge.** - -* If I have more questions, who can I contact? - **Please reply to the email who sent you this test.**