Pārlūkot izejas kodu

Initial Vendimia Refactor

Tomás Ponce Gessi 3 gadi atpakaļ
revīzija
7dd178edfc
100 mainītis faili ar 60358 papildinājumiem un 0 dzēšanām
  1. 131 0
      .gitignore
  2. 7 0
      HACKING.md
  3. 6 0
      README.md
  4. 27 0
      backend/Dockerfile
  5. 0 0
      backend/__init__.py
  6. 36 0
      backend/app.py
  7. 0 0
      backend/client/__init__.py
  8. 14 0
      backend/client/loginClient.py
  9. 87 0
      backend/client/measures_client.py
  10. 87 0
      backend/commands/fincas.py
  11. 294 0
      backend/commands/measures.py
  12. 5 0
      backend/commands/runserver.py
  13. 9 0
      backend/config/settings.py
  14. 9 0
      backend/crons
  15. 0 0
      backend/cruds/__init__.py
  16. 42 0
      backend/cruds/companies.py
  17. 43 0
      backend/cruds/finca_company.py
  18. 58 0
      backend/cruds/fincas.py
  19. 29 0
      backend/cruds/measures.py
  20. 47 0
      backend/cruds/users.py
  21. 25 0
      backend/cruds/utils.py
  22. 0 0
      backend/database/__init__.py
  23. 6 0
      backend/database/mongo.py
  24. 18 0
      backend/database/sqlalchemy.py
  25. 17 0
      backend/docker-entrypoint.sh
  26. 25 0
      backend/main.py
  27. 0 0
      backend/models/__init__.py
  28. 14 0
      backend/models/company.py
  29. 15 0
      backend/models/finca_company.py
  30. 15 0
      backend/models/fincas.py
  31. 18 0
      backend/models/users.py
  32. 36 0
      backend/requirements.txt
  33. 0 0
      backend/routes/__init__.py
  34. 97 0
      backend/routes/calculations/degrees_accumulated.py
  35. 98 0
      backend/routes/calculations/gnral_summary.py
  36. 65 0
      backend/routes/calculations/montly_precip.py
  37. 203 0
      backend/routes/calculations/pipelines.py
  38. 78 0
      backend/routes/companies.py
  39. 40 0
      backend/routes/fincas.py
  40. 148 0
      backend/routes/login.py
  41. 211 0
      backend/routes/measures.py
  42. 0 0
      backend/schemas/__init__.py
  43. 15 0
      backend/schemas/company.py
  44. 9 0
      backend/schemas/finca_company.py
  45. 16 0
      backend/schemas/fincas.py
  46. 9 0
      backend/schemas/measures.py
  47. 36 0
      backend/schemas/tables.py
  48. 17 0
      backend/schemas/users.py
  49. 77 0
      docker-compose.yml
  50. 3 0
      frontend/.dockerignore
  51. 9 0
      frontend/Dockerfile
  52. 31 0
      frontend/Dockerfile.production
  53. 9 0
      frontend/nginx.conf
  54. 20091 0
      frontend/package-lock.json
  55. 35888 0
      frontend/package-lockdaold.json
  56. 54 0
      frontend/package.json
  57. BIN
      frontend/public/favicon.ico
  58. 38 0
      frontend/public/index.html
  59. BIN
      frontend/public/logo192.png
  60. BIN
      frontend/public/logo512.png
  61. 25 0
      frontend/public/manifest.json
  62. 3 0
      frontend/public/robots.txt
  63. 9 0
      frontend/src/App.test.tsx
  64. 27 0
      frontend/src/App.tsx
  65. 20 0
      frontend/src/api/auth.ts
  66. 2 0
      frontend/src/api/index.ts
  67. 172 0
      frontend/src/api/mocks.ts
  68. 1 0
      frontend/src/api/shared.ts
  69. 134 0
      frontend/src/api/tables.ts
  70. 54 0
      frontend/src/components/UI/dashboard/checkboxTables/index.tsx
  71. 159 0
      frontend/src/components/UI/dashboard/cockpit/index.tsx
  72. 52 0
      frontend/src/components/UI/dashboard/detail/index.tsx
  73. 29 0
      frontend/src/components/UI/forms/calendarInput.tsx
  74. 35 0
      frontend/src/components/UI/forms/select.tsx
  75. 88 0
      frontend/src/components/data/DegreeDay/index.tsx
  76. 102 0
      frontend/src/components/data/GeneralPerSeason/index.tsx
  77. 119 0
      frontend/src/components/data/GeneralPerSector/index.tsx
  78. 6 0
      frontend/src/components/data/HeatMap/index.tsx
  79. 88 0
      frontend/src/components/data/Precipitation/index.tsx
  80. 118 0
      frontend/src/components/data/shared.tsx
  81. 54 0
      frontend/src/components/data/tables.module.css
  82. 42 0
      frontend/src/components/hoc/ErrorBoundary.tsx
  83. 7 0
      frontend/src/components/layout/footer.tsx
  84. 2 0
      frontend/src/components/layout/header.module.css
  85. 17 0
      frontend/src/components/layout/header.tsx
  86. 27 0
      frontend/src/components/layout/index.tsx
  87. 32 0
      frontend/src/components/layout/sidebar.tsx
  88. 38 0
      frontend/src/components/pages/Home.tsx
  89. 29 0
      frontend/src/components/pages/Login.module.css
  90. 151 0
      frontend/src/components/pages/Login.tsx
  91. 92 0
      frontend/src/components/portals/modal/index.tsx
  92. 2 0
      frontend/src/components/portals/modal/modal.module.css
  93. 5 0
      frontend/src/config/index.ts
  94. 38 0
      frontend/src/context/auth/AuthProvider.tsx
  95. 20 0
      frontend/src/context/auth/actionTypes.ts
  96. 71 0
      frontend/src/context/auth/actions.ts
  97. 59 0
      frontend/src/context/auth/reducer.ts
  98. 48 0
      frontend/src/context/dashboard/Provider.tsx
  99. 19 0
      frontend/src/context/dashboard/actionTypes.ts
  100. 0 0
      frontend/src/context/dashboard/actions.ts

+ 131 - 0
.gitignore

@@ -0,0 +1,131 @@
+# === START GENERAL IGNORES: ===
+
+mongo_data
+postgres_data
+*.env*
+
+# === END GENERAL IGNORES ===
+
+# === START BACKEND IGNORES: ===
+
+# ---> Python
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Sqlite Database
+app/database/sqlite.db
+
+# Data dumps
+app/dumps/
+
+# === END BACKEND IGNORES ===
+
+# === START FRONTEND IGNORES: ===
+
+# ---> Node and React
+# Logs
+logs
+*.log
+npm-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+
+# dependencies
+**/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# === END FRONTEND IGNORES ===
+

+ 7 - 0
HACKING.md

@@ -0,0 +1,7 @@
+# Estilo de código
+
+## Backend:
+
+Para mantener un estilo consistente de código, usamos el siguiente comando para formatear todo automáticamente:
+
+    $ isort --profile black . && black .

+ 6 - 0
README.md

@@ -0,0 +1,6 @@
+# zucardi
+
+Repositorio de codigo para el backend(python) del sistema para monitoreo de datos de estaciones meteorologicas de las fincas Zucardi.
+
+Por ahora estamos usando:
+- Python 3.8.6

+ 27 - 0
backend/Dockerfile

@@ -0,0 +1,27 @@
+FROM python:3.9-alpine
+
+# don't write .pyc files on import
+ENV PYTHONDONTWRITEBYTECODE 1
+
+# force stdout and stderr to be unbuffered
+ENV PYTHONUNBUFFERED 1
+RUN apk --update add gcc make g++ zlib-dev mc nmap bash postgresql-client postgresql-dev
+RUN cp /usr/share/zoneinfo/America/Argentina/Cordoba /etc/localtime
+
+#RUN addgroup -S python && adduser -S python -G python
+
+#USER python
+COPY ./requirements.txt .
+RUN pip install -r requirements.txt
+
+
+COPY . /app
+WORKDIR /app
+
+# Add Crons:
+ADD ./crons /etc/crontabs/root
+
+COPY ./docker-entrypoint.sh /docker-entrypoint.sh
+
+EXPOSE 8000
+ENTRYPOINT [ "ash", "/docker-entrypoint.sh" ]

+ 0 - 0
backend/__init__.py


+ 36 - 0
backend/app.py

@@ -0,0 +1,36 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from starlette.responses import RedirectResponse
+
+from config.settings import ROOT_PATH
+from database.sqlalchemy import engine
+from models.users import Base
+from routes.companies import router as CompaniesRouter
+from routes.fincas import router as FincasRouter
+from routes.login import router as LoginRouter
+from routes.measures import router as MeasuresRouter
+
+app = FastAPI(
+    title="Viñedos API",
+    root_path=ROOT_PATH,
+)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+Base.metadata.create_all(bind=engine)
+
+
+@app.get("/", include_in_schema=False)
+async def root():
+    return RedirectResponse(url="/docs")
+
+
+app.include_router(LoginRouter, tags=["Login"])
+app.include_router(MeasuresRouter, tags=["Measures"])
+app.include_router(CompaniesRouter, tags=["Companies"])
+app.include_router(FincasRouter, tags=["Fincas"])

+ 0 - 0
backend/client/__init__.py


+ 14 - 0
backend/client/loginClient.py

@@ -0,0 +1,14 @@
+import requests
+
+from config.settings import CLIMA_URL
+
+
+def get_auth_token(username: str, password: str) -> requests.Response:
+    try:
+        response = requests.post(
+            f"{CLIMA_URL}api/zuccardi/get_auth_token/",
+            data={"username": username, "password": password},
+        )
+        return response
+    except:
+        return None

+ 87 - 0
backend/client/measures_client.py

@@ -0,0 +1,87 @@
+import logging
+from datetime import datetime
+from os import path
+from typing import List, Optional
+
+import requests
+from fastapi.encoders import jsonable_encoder
+from requests.models import Response
+
+from database.mongo import mongo_measures
+from schemas.fincas import Finca
+from schemas.measures import Measure
+
+# Logging
+# Quiza deberiamos centralizar todos los loggers en su propio modulo
+filename = path.abspath("./measures_client.log")
+logger = logging.getLogger("measures_client")
+# handler = logging.FileHandler(filename)
+# logger.addHandler(handler)
+
+
+class ClimaMeasuresClient:
+    token: str
+    url: str
+
+    def __init__(self, token: str, url: str) -> None:
+        self.token = token
+        self.url = url
+
+    def _get_raw_station_measures(
+            self, station_code: str, datetime_from: datetime, datetime_to: Optional[datetime] = None, limit: Optional[int] = None 
+    ):
+        parameters: dict[str, str | int] = {
+            "token": self.token,
+            "date_from": datetime_from.isoformat(timespec="microseconds"),
+        }
+        if(datetime_to):
+            parameters["date_to"] = datetime_to.isoformat(timespec="microseconds")
+        if(limit):
+            parameters["limit"] = limit
+        url = self.url + f"import/{station_code}"
+
+        response = requests.get(url, parameters)
+        if response.ok:
+            return response.json()
+        elif response.status_code == 400:
+            logger.error(f"Bad Request al consultar measures: Station {station_code}")
+        elif response.status_code == 403:
+            logger.error(
+                f"Acceso denegado: Station {station_code}, Token: {self.token}"
+            )
+        elif response.status_code == 404:
+            logger.error(
+                f"Estacion no encontrada al consultar sus datos: Station {station_code}"
+            )
+        else:
+            logger.error(f"Error {response.status_code}: Station {station_code}")
+        raise Exception(f"Response status({response.text}) no ok al consultar muestras")
+
+    def get_station_measures(
+        self, station_code: str, datetime_from: datetime, datetime_to: Optional[datetime] = None, limit: Optional[int] = None 
+    ) -> List[Measure]:
+
+        if(limit is None and datetime_to is None):
+            raise ValueError("Los parámetros datetime_to y max_count no pueden ser ambos None, incluir al menos uno")
+        data = self._get_raw_station_measures(station_code, datetime_from, datetime_to, limit)
+
+        return list(map(lambda x: Measure(**x), data))
+
+    def _get_raw_stations(
+        self,
+        company_code: str,
+    ) -> Response:
+        parameters = {
+            "token": self.token,
+        }
+        url = self.url + f"get-company-stations/{company_code}"
+
+        response = requests.get(url, parameters)
+        if response.status_code == 200:
+            return response.json()
+
+        raise PermissionError()
+
+    def get_stations(self, company_code: str) -> List[Finca]:
+        data = self._get_raw_stations(company_code)
+        return list(map(lambda x: Finca(**x), data))

+ 87 - 0
backend/commands/fincas.py

@@ -0,0 +1,87 @@
+import typer
+from sqlalchemy.orm.session import Session
+
+from client.measures_client import ClimaMeasuresClient
+from config.settings import CLIMA_API_URL
+from cruds.finca_company import create_finca_company, delete_finca_company
+from cruds.fincas import (
+    create_finca,
+    get_finca_by_station_code,
+    get_fincas_filter_company,
+    update_finca_title,
+)
+from database.sqlalchemy import SessionLocal
+from models.users import User
+from schemas.finca_company import FincaCompany as FincaCompanySchema
+
+
+def update_fincas(company_id: int):
+    db: Session = SessionLocal()
+    user = db.query(User).first()
+
+    client = ClimaMeasuresClient(
+        token=user.token,
+        url=CLIMA_API_URL,
+    )
+
+    fincas_api = client.get_stations(str(company_id))
+    fincas_local = get_fincas_filter_company(db, company_id)
+    added = 0
+    deleted = 0
+    updated = 0
+
+    # Agregar Fincas que esten en clima pero no en este sistema
+    for finca_api in fincas_api:
+        if not any(
+            finca_local.station_code == finca_api.station_code
+            for finca_local in fincas_local
+        ):
+            if not get_finca_by_station_code(db, finca_api.station_code):
+                finca = create_finca(db=db, finca=finca_api)
+            else:
+                finca = get_finca_by_station_code(db, finca_api.station_code)
+            finca_company = FincaCompanySchema(
+                finca_station_code=finca.station_code, company_id=company_id
+            )
+            create_finca_company(db, finca_company)
+            added += 1
+
+    # Eliminar Relaciones con Fincas que no esten en clima pero si en este sistema
+    for finca_local in fincas_local:
+        if not any(
+            finca_local.station_code == finca_api.station_code
+            for finca_api in fincas_api
+        ):
+            delete_finca_company(
+                db=db, company_id=company_id, station_code=finca_local.station_code
+            )
+            deleted += 1
+
+    fincas_local = get_fincas_filter_company(db, company_id)
+
+    # Mantener congruencia de titulos
+    for finca_local in fincas_local:
+        finca_api = next(
+            (x for x in fincas_api if x.station_code == finca_local.station_code),
+            None,
+        )
+        if finca_local.title != finca_api.title:
+            update_finca_title(
+                db=db, station_code=finca_local.station_code, new_title=finca_api.title
+            )
+            updated += 1
+
+    response = {
+        "added_fincas": added,
+        "deleted_fincas": deleted,
+        "updated_fincas": updated,
+    }
+
+    return response
+
+
+def update_fincas_console(
+    company_id: int = typer.Option("", "--id"),
+):
+
+    return update_fincas(company_id)

+ 294 - 0
backend/commands/measures.py

@@ -0,0 +1,294 @@
+import json
+import os
+from datetime import date, datetime, timedelta
+from logging import FileHandler, getLogger
+from os import path
+from pathlib import Path
+from time import sleep
+from typing import Callable, Optional
+
+import typer
+from pymongo.operations import UpdateOne
+from sqlalchemy.orm.session import Session
+
+from client.measures_client import ClimaMeasuresClient
+from config.settings import CLIMA_API_URL
+from database.mongo import mongo_measures
+from database.sqlalchemy import SessionLocal
+from models.fincas import Finca
+from models.users import User
+from schemas.measures import Measure
+
+# Logging
+# Quiza deberiamos centralizar todos los loggers en su propio modulo
+filename = path.abspath("./import_measures.log")
+logger = getLogger("import_measures")
+# handler = FileHandler(filename)
+# logger.addHandler(handler)
+
+
+def _insert_or_update_data(finca_data, data):
+    measures = []
+    for x in data:
+        measures.append({"temp": x.temp, "precip": x.precip, "date": x.date})
+
+    # Probamos insertar todas, si no se pudo,
+    # intentamos agregar/actualizar una por una.
+    try:
+        # Eso puede fallar por limite de inserciones o por tener measures ya existentes
+        finca_data.insert_many(measures)
+    except:
+        msg = "\tNo se pudieron insertar measures en bulk, probando con actualizar uno por uno"
+        logger.info(msg)
+        typer.echo(msg)
+        operations = []
+        max_date = datetime.min
+        for measure in data:
+            max_date = measure.date if measure.date > max_date else max_date
+
+            operations.append(
+                UpdateOne(
+                    {"date": measure.date},
+                    {"$set": dict(measure)},
+                    upsert=True,
+                )
+            )
+        finca_data.bulk_write(operations)
+
+    last_date = max(measure["date"] for measure in measures)
+    return last_date
+
+
+def _import_finca_range(date_from, date_to, code, client: ClimaMeasuresClient):
+    finca_data = mongo_measures[code]
+
+    # Fecha movil que vamos incrementando
+    start_date = date_from
+    typer.echo(f"Importando measures de {code}")
+    logger.info(f"Importando measures de {code}")
+    while start_date < date_to:
+        # 10 dias mas o la fecha maxima del rango, la que sea mas chica
+        end_date = min(start_date + timedelta(days=10), date_to)
+
+        msg = f"Consultando datos desde {start_date} hasta {end_date}]"
+        logger.info(msg)
+        typer.echo(msg)
+        data = []
+        try:
+            data = client.get_station_measures(code, start_date, end_date)
+        except:
+            logger.error(
+                f"No se pudo importar las measures de la estacion {code}, ver logs del cliente para mas info"
+            )
+            # Si hay un error al traer una porcion de los datos, directamente salimos,
+            # ya que el error no depende de las fechas pedidas
+            # Por eso return y no continue
+            return
+        if len(data) > 0:
+            _insert_or_update_data(finca_data, data)
+        else:
+            msg = "Porcion de datos vacia"
+            logger.info(msg)
+            typer.echo(msg)
+        start_date = end_date
+        if end_date != date_to:
+            sleep(5)
+
+
+def import_range(
+    date_from: datetime = typer.Argument(...),
+    date_to: datetime = typer.Argument(...),
+    fincas_codes: list[int] = typer.Argument(None),
+):
+    typer.echo(
+        f"Importando datos desde {date_from} hasta {date_to} para las fincas: {fincas_codes if len(fincas_codes) > 0 else 'todas'}"
+    )
+    if not (date_from < date_to):
+        typer.echo("La fecha de inicio debe se menor a la fecha de termino")
+        typer.echo("Para ver los argumentos usar `python main.py import-range --help`")
+    db: Session = SessionLocal()
+    user = db.query(User).first()
+    client = ClimaMeasuresClient(
+        token=user.token,
+        url=CLIMA_API_URL,
+    )
+
+    # Si el campo opcional fincas_codes no es dado, usamos todas las fincas de la db
+    if len(fincas_codes) == 0:
+        typer.echo("No fincas dadas")
+        db: Session = SessionLocal()
+        fincas = db.query(Finca).all()
+        for finca in fincas:
+            _import_finca_range(date_from, date_to, finca.station_code, client)
+    else:
+        for code in fincas_codes:
+            _import_finca_range(date_from, date_to, str(code), client)
+    db.commit()
+    db.close()
+
+
+def import_measures():
+    db: Session = SessionLocal()
+    fincas = db.query(Finca).all()
+    user = db.query(User).first()
+
+    # Importamos finca por finca
+    for finca in fincas:
+        typer.echo(f"Importando measures de {finca.station_code}")
+        logger.info(f"Importando measures de {finca.station_code}")
+        finca_data = mongo_measures[finca.station_code]
+
+        # Desde la ultima vez
+        date_from = finca.last_synced
+        if date_from is None:
+            typer.echo(
+                f"\tEl atributo last_synced es nulo en la estacion {finca.station_code}."
+            )
+            logger.warning(
+                f"\tEl atributo last_synced es nulo en la estacion {finca.station_code}."
+            )
+            continue
+
+        # Las muestras que habría normalmente en 10 días de datos con datos cada 10 minutos
+        # 24 horas x 6 muestras
+        limit = 1440
+
+        print(f"\tfrom: {date_from}")
+
+        # Cliente de api clima
+        client = ClimaMeasuresClient(
+            token=user.token,
+            url=CLIMA_API_URL,
+        )
+
+        # Probamos traer los datos
+        # Ante cualquier error seguimos con la proxima estacion
+        data = None
+        try:
+            data = client.get_station_measures(finca.station_code, date_from, limit=limit)
+        except Exception as e:
+            logger.error(
+                f"No se pudo importar las measures de la estacion {finca.station_code}: {e}, ver logs del cliente para mas info"
+            )
+            continue
+
+        if len(data) > 0:
+
+            last_date = _insert_or_update_data(finca_data, data)
+
+            finca.last_synced = last_date
+        else:
+            typer.echo(f"\tNo hay datos a partir de {date_from}")
+
+        db.commit()
+
+    db.close()
+
+
+def _apply_function_conditional(station_code: str, func: Callable[[Finca], None]):
+    db: Session = SessionLocal()
+
+    if station_code is None:
+        fincas = db.query(Finca).all()
+    else:
+        fincas = db.query(Finca).filter(Finca.station_code == station_code)
+
+    for finca in fincas:
+        func(finca)
+
+    db.close()
+
+
+def _get_measures(finca: Finca):
+    finca_data = mongo_measures[finca.station_code]
+
+    query = finca_data.find()
+    for x in query:
+        typer.echo(x)
+
+
+def get_measures(station_code: str = typer.Argument(None)):
+    _apply_function_conditional(station_code, _get_measures)
+
+
+def _delete_measures(finca: str):
+    finca_data = mongo_measures[finca.station_code]
+    finca_data.delete_many({})
+
+
+def delete_measures(station_code: str = typer.Argument(None)):
+    _apply_function_conditional(station_code, _delete_measures)
+
+
+def import_json_dump(
+    dumppath: Path = typer.Option("", "--dump"),
+    station_code: str = typer.Option("", "--code"),
+    all: bool = typer.Option(False, "--all"),
+):
+    db: Session = SessionLocal()
+    fincas = []
+
+    if all:
+        for filename in os.listdir("dumps"):
+            code = Path(filename).stem
+            finca = db.query(Finca).filter(Finca.station_code == code).first()
+            fincas.append(finca)
+    else:
+        finca = db.query(Finca).filter(Finca.station_code == station_code).first()
+        fincas.append(finca)
+
+    for finca in fincas:
+        if finca is None:
+            print(f"La finca con el código de estación {station_code} no existe.")
+            continue
+
+        typer.echo(f"Importando datos de la estación: {finca.station_code}")
+
+        if all:
+            dumpfile = open(Path(f"dumps/{finca.station_code}.json"))
+        else:
+            dumpfile = open(dumppath)
+
+        dump = json.load(dumpfile)
+
+        finca_data = mongo_measures[finca.station_code]
+
+        # Create date index, otherwise it would take ages to upsert every document
+        # REVIEW: Might want to move this index code somewhere else
+        indexes = finca_data.index_information()
+        if indexes.get("date_1") is None:
+            typer.echo("\tCreating `date` index")
+            finca_data.create_index("date", unique=True)
+
+        operations = []
+        max_date = datetime.min
+        with typer.progressbar(dump, label="Procesando datos") as progress:
+            for measure in progress:
+                new_measure = Measure(**measure)
+                max_date = new_measure.date if new_measure.date > max_date else max_date
+
+                operations.append(
+                    UpdateOne(
+                        {"date": new_measure.date},
+                        {"$set": dict(new_measure)},
+                        upsert=True,
+                    )
+                )
+
+        last_synced = (
+            finca.last_synced if finca.last_synced is not None else datetime.min
+        )
+        if max_date > last_synced:
+            typer.echo(f"Updating last_synced: {max_date}")
+            finca.last_synced = max_date
+
+        typer.echo("Bulk writing operations in mongo...")
+        result = finca_data.bulk_write(operations)
+
+        typer.echo(f"Updated measures: {result.modified_count}")
+        typer.echo(f"Inserted measures {result.upserted_count}")
+
+        dumpfile.close()
+
+    db.commit()
+    db.close()

+ 5 - 0
backend/commands/runserver.py

@@ -0,0 +1,5 @@
+import uvicorn
+
+
+def runserver(host: str = "0.0.0.0", port: int = 8000, reload: bool = False):
+    uvicorn.run("app:app", host=host, port=port, reload=reload)

+ 9 - 0
backend/config/settings.py

@@ -0,0 +1,9 @@
+import os
+
+ROOT_PATH = os.environ.get("FA_ROOT_PATH") or ""
+POSTGRES_URL = os.environ.get("POSTGRES_URL") or ""
+MONGO_URL = os.environ.get("MONGO_URL") or ""
+CLIMA_URL = os.environ.get("CLIMA_URL") or ""
+CLIMA_API_URL = os.environ.get("CLIMA_API_URL") or ""
+
+DECIMAL_PLACES = 1

+ 9 - 0
backend/crons

@@ -0,0 +1,9 @@
+# min	hour	day	month	weekday	command
+*/15	*	*	*	*	run-parts /etc/periodic/15min
+0	*	*	*	*	run-parts /etc/periodic/hourly
+0	2	*	*	*	run-parts /etc/periodic/daily
+0	3	*	*	6	run-parts /etc/periodic/weekly
+0	5	1	*	*	run-parts /etc/periodic/monthly
+
+# Importa las muestras de las fincas
+5 * * * * python /app/main.py import-measures

+ 0 - 0
backend/cruds/__init__.py


+ 42 - 0
backend/cruds/companies.py

@@ -0,0 +1,42 @@
+from typing import List
+
+from sqlalchemy.orm import Session
+from sqlalchemy.sql.sqltypes import Boolean
+
+from models.company import Company as CompanyModel
+from schemas.company import Company as CompanySchema
+
+
+def get_companies(db: Session, skip: int = 0, limit: int = 100) -> List[CompanyModel]:
+    return db.query(CompanyModel).all()
+
+
+def get_company_by_id(db: Session, id: int) -> CompanyModel:
+    return db.query(CompanyModel).filter(CompanyModel.id == id).first()
+
+
+def get_company_by_title(db: Session, title: str) -> CompanyModel:
+    return db.query(CompanyModel).filter(CompanyModel.title == title).first()
+
+
+def create_company(db: Session, company: CompanySchema) -> CompanyModel:
+    db_company = CompanyModel(id=company.id, title=company.title)
+    db.add(db_company)
+    db.commit()
+    db.refresh(db_company)
+    return db_company
+
+
+def delete_company(db: Session, id: str) -> Boolean:
+    company = db.query(CompanyModel).filter(CompanyModel.id == id).first()
+    db.delete(company)
+    db.commit()
+    return True
+
+
+def update_company_title(db: Session, id: str, new_title: str) -> CompanyModel:
+    company = db.query(CompanyModel).filter(CompanyModel.id == id).first()
+    company.title = new_title
+    db.commit()
+    db.refresh(company)
+    return company

+ 43 - 0
backend/cruds/finca_company.py

@@ -0,0 +1,43 @@
+from typing import List
+
+from sqlalchemy.orm import Session
+from sqlalchemy.sql.sqltypes import Boolean
+
+from cruds.utils import order_fincas
+from models.finca_company import FincaCompany as FincaCompanyModel
+from schemas.finca_company import FincaCompany as FincaCompanySchema
+
+
+def get_finca_company_by_company_id(db: Session, company_id: int) -> FincaCompanyModel:
+    return (
+        db.query(FincaCompanyModel)
+        .filter(FincaCompanyModel.company_id == company_id)
+        .all()
+    )
+
+
+def create_finca_company(
+    db: Session, finca_company: FincaCompanySchema
+) -> FincaCompanyModel:
+    db_finca_company = FincaCompanyModel(
+        company_id=finca_company.company_id,
+        finca_station_code=finca_company.finca_station_code,
+    )
+    db.add(db_finca_company)
+    db.commit()
+    db.refresh(db_finca_company)
+    return db_finca_company
+
+
+def delete_finca_company(db: Session, company_id: int, station_code: str) -> Boolean:
+    finca_company = (
+        db.query(FincaCompanyModel)
+        .filter(
+            FincaCompanyModel.company_id == company_id,
+            FincaCompanyModel.finca_station_code == station_code,
+        )
+        .first()
+    )
+    db.delete(finca_company)
+    db.commit()
+    return True

+ 58 - 0
backend/cruds/fincas.py

@@ -0,0 +1,58 @@
+from datetime import datetime
+from typing import List
+
+from sqlalchemy.orm import Session
+from sqlalchemy.sql.sqltypes import Boolean
+
+from cruds.finca_company import get_finca_company_by_company_id
+from cruds.utils import order_fincas
+from models.company import Company
+from models.fincas import Finca as FincaModel
+from schemas.fincas import Finca as FincaSchema
+
+
+def get_fincas(db: Session, skip: int = 0, limit: int = 100) -> List[FincaModel]:
+    return order_fincas(db.query(FincaModel).offset(skip).limit(limit).all())
+
+
+def get_fincas_filter_company(db: Session, company_id: int) -> List[FincaModel]:
+    fincas_company = get_finca_company_by_company_id(db, company_id)
+    station_codes = [f.finca_station_code for f in fincas_company]
+    return order_fincas(
+        db.query(FincaModel).filter(FincaModel.station_code.in_(station_codes)).all()
+    )
+
+
+def get_finca_by_station_code(db: Session, station_code: str) -> FincaModel:
+    return db.query(FincaModel).filter(FincaModel.station_code == station_code).first()
+
+
+def get_finca_by_title(db: Session, title: str) -> FincaModel:
+    return db.query(FincaModel).filter(FincaModel.title == title).first()
+
+
+def create_finca(db: Session, finca: FincaSchema) -> FincaModel:
+    db_finca = FincaModel(
+        station_code=finca.station_code,
+        title=finca.title,
+        last_synced=finca.last_synced or datetime.now(),
+    )
+    db.add(db_finca)
+    db.commit()
+    db.refresh(db_finca)
+    return db_finca
+
+
+def delete_finca(db: Session, station_code: str) -> Boolean:
+    finca = db.query(FincaModel).filter(FincaModel.station_code == station_code).first()
+    db.delete(finca)
+    db.commit()
+    return True
+
+
+def update_finca_title(db: Session, station_code: str, new_title: str) -> FincaModel:
+    finca = db.query(FincaModel).filter(FincaModel.station_code == station_code).first()
+    finca.title = new_title
+    db.commit()
+    db.refresh(finca)
+    return finca

+ 29 - 0
backend/cruds/measures.py

@@ -0,0 +1,29 @@
+from datetime import datetime
+from typing import List
+
+from database.mongo import mongo_measures
+
+
+def get_finca_measures(
+    station_code: str, start_datetime: datetime, end_datetime: datetime
+) -> List:
+
+    finca_data = mongo_measures[station_code]
+
+    _filter = {"date": {"$gt": start_datetime, "$lte": end_datetime}}
+
+    query = finca_data.find(_filter, sort=(("date", 1),))
+
+    measures = []
+
+    for measure in query:
+        measures.append(
+            {
+                "temp": measure["temp"],
+                "hum": measure["hum"],
+                "precip": measure["precip"],
+                "date": measure["date"],
+            }
+        )
+
+    return measures

+ 47 - 0
backend/cruds/users.py

@@ -0,0 +1,47 @@
+from typing import List
+
+from passlib.hash import sha256_crypt
+from sqlalchemy.orm import Session
+
+from models.users import User as UserModel
+from schemas.users import User as UserSchema
+
+
+def get_user(db: Session, user_id: int) -> UserModel:
+
+    return db.query(UserModel).filter(UserModel.id == user_id).first()
+
+
+def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[UserModel]:
+
+    return db.query(UserModel).offset(skip).limit(limit).all()
+
+
+def get_user_by_username(db: Session, username: str) -> UserModel:
+    return db.query(UserModel).filter(UserModel.username == username).first()
+
+
+def get_user_by_token(db: Session, token: str) -> UserModel:
+    return db.query(UserModel).filter(UserModel.token == token).first()
+
+
+def create_user(db: Session, user: UserSchema) -> UserModel:
+    hashed_password = sha256_crypt.encrypt(user.password)
+    db_user = UserModel(
+        username=user.username,
+        password=hashed_password,
+        token=user.token,
+        company_id=user.company_id,
+    )
+    db.add(db_user)
+    db.commit()
+    db.refresh(db_user)
+    return db_user
+
+
+def update_password(db: Session, db_user: UserModel, new_password: str) -> UserModel:
+    new_hashed_password = sha256_crypt.encrypt(new_password)
+    db_user.password = new_hashed_password
+    db.commit()
+    db.refresh(db_user)
+    return db_user

+ 25 - 0
backend/cruds/utils.py

@@ -0,0 +1,25 @@
+from typing import List
+
+from models.fincas import Finca
+
+ORDER = [
+    "San Pablo",
+    "Gualtallary",
+    "Paraje Altamira",
+    "Vista Flores",
+    "La Ribera",
+    "Maipú",
+    "Santa Rosa",
+]
+
+
+def safe_index(v):
+    try:
+        return ORDER.index(v)
+    except Exception as e:
+        return float("inf")
+
+
+def order_fincas(fincas: List[Finca]) -> List[Finca]:
+    ordered_fincas = sorted(fincas, key=lambda e: safe_index(e.title))
+    return ordered_fincas

+ 0 - 0
backend/database/__init__.py


+ 6 - 0
backend/database/mongo.py

@@ -0,0 +1,6 @@
+from pymongo import MongoClient
+
+from config.settings import MONGO_URL
+
+client: MongoClient = MongoClient(MONGO_URL)
+mongo_measures = client.measures

+ 18 - 0
backend/database/sqlalchemy.py

@@ -0,0 +1,18 @@
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+from config.settings import POSTGRES_URL
+
+engine = create_engine(POSTGRES_URL)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+# Dependency
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()

+ 17 - 0
backend/docker-entrypoint.sh

@@ -0,0 +1,17 @@
+#!/bin/bash
+set -xe
+
+# -l es el loglevel. -L escribe el output en el archivo
+crond -l 0 -L /cronlogs.txt
+
+cd /app
+
+if [ "${VENDIMIA_ENV}" == "development" ]
+then
+    python main.py runserver --reload
+elif [ "${VENDIMIA_ENV}" == "production" ]
+then
+    python main.py runserver --host 0.0.0.0 --port 8000
+else
+    echo "Unknown Vendimia ENV:"
+fi

+ 25 - 0
backend/main.py

@@ -0,0 +1,25 @@
+import typer
+
+from commands.fincas import update_fincas_console as update_fincas
+from commands.measures import (
+    delete_measures,
+    get_measures,
+    import_json_dump,
+    import_measures,
+    import_range,
+)
+from commands.runserver import runserver
+
+typer_app = typer.Typer()
+
+
+typer_app.command()(runserver)
+typer_app.command()(import_measures)
+typer_app.command()(import_range)
+typer_app.command()(get_measures)
+typer_app.command()(delete_measures)
+typer_app.command()(import_json_dump)
+typer_app.command()(update_fincas)
+
+if __name__ == "__main__":
+    typer_app()

+ 0 - 0
backend/models/__init__.py


+ 14 - 0
backend/models/company.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.orm import relationship
+
+from database.sqlalchemy import Base
+
+
+class Company(Base):
+    __tablename__ = "organizaciones"
+
+    id = Column(Integer, primary_key=True, index=True)
+    title = Column(String)
+
+    users = relationship("User", back_populates="company")
+    fincas = relationship("FincaCompany", back_populates="company")

+ 15 - 0
backend/models/finca_company.py

@@ -0,0 +1,15 @@
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship
+
+from database.sqlalchemy import Base
+
+
+class FincaCompany(Base):
+    __tablename__ = "finca_organizacion"
+
+    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+    company_id = Column(Integer, ForeignKey("organizaciones.id"))
+    finca_station_code = Column(String, ForeignKey("fincas.station_code"))
+
+    company = relationship("Company", back_populates="fincas")
+    finca = relationship("Finca", back_populates="companies")

+ 15 - 0
backend/models/fincas.py

@@ -0,0 +1,15 @@
+from sqlalchemy import Column, DateTime, Integer, String
+from sqlalchemy.orm import relationship
+
+from database.sqlalchemy import Base
+
+
+class Finca(Base):
+    __tablename__ = "fincas"
+
+    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+    station_code = Column(String, primary_key=True, unique=True, index=True)
+    title = Column(String)
+    last_synced = Column(DateTime, nullable=True)
+
+    companies = relationship("FincaCompany", back_populates="finca")

+ 18 - 0
backend/models/users.py

@@ -0,0 +1,18 @@
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship
+
+from database.sqlalchemy import Base
+
+from .company import Company
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+    username = Column(String, unique=True, index=True)
+    password = Column(String)
+    token = Column(String)
+    company_id = Column(Integer, ForeignKey("organizaciones.id"))
+
+    company = relationship("Company", back_populates="users")

+ 36 - 0
backend/requirements.txt

@@ -0,0 +1,36 @@
+asgiref==3.4.1
+black==21.9b0
+certifi==2021.10.8
+charset-normalizer==2.0.7
+click==8.0.1
+fastapi==0.68.1
+greenlet==1.1.2
+h11==0.12.0
+httptools==0.2.0
+idna==3.3
+isort==5.9.3
+motor==2.5.1
+mypy-extensions==0.4.3
+passlib==1.7.4
+pathspec==0.9.0
+platformdirs==2.4.0
+psycopg2-binary==2.8.5
+pydantic==1.8.2
+pymongo==3.12.0
+python-dateutil==2.8.2
+python-dotenv==0.19.0
+python-multipart==0.0.5
+PyYAML==5.4.1
+regex==2021.9.24
+requests==2.26.0
+six==1.16.0
+SQLAlchemy==1.4.25
+starlette==0.14.2
+tomli==1.2.1
+typer==0.4.0
+typing-extensions==3.10.0.2
+urllib3==1.26.7
+uvicorn==0.15.0
+uvloop==0.16.0
+watchgod==0.7
+websockets==10.0

+ 0 - 0
backend/routes/__init__.py


+ 97 - 0
backend/routes/calculations/degrees_accumulated.py

@@ -0,0 +1,97 @@
+from datetime import datetime
+from pprint import pprint
+
+from config.settings import DECIMAL_PLACES
+from database.mongo import mongo_measures
+from routes.calculations.pipelines import count_temp_measures_pipeline, get_percent
+from schemas.fincas import FincaBase as FincaBaseSchema
+from schemas.tables import (
+    DegreesAccumulatedAvgMonth as DegreesAccumulatedAvgMonthSchema,
+)
+
+
+def ensure_non_empty(result, key):
+    """
+    Ensures that the result list is not empty and that we are not rounding None
+    """
+    if len(result) > 0:
+        value = result[0].get(key)
+        if value is not None:
+            return round(value, 2)
+
+
+def monthly_avr_day_degrees(
+    finca: FincaBaseSchema, start_datetime: datetime, end_datetime: datetime
+) -> DegreesAccumulatedAvgMonthSchema:
+    finca_data = mongo_measures[finca.station_code]
+
+    degrees_accumulated_agg = finca_data.aggregate(
+        [
+            {
+                "$match": {
+                    "date": {
+                        "$gte": start_datetime,
+                        "$lte": end_datetime,
+                    },
+                    "temp": {"$ne": None},
+                },
+            },
+            {
+                "$group": {
+                    "_id": {
+                        "day": {"$dayOfMonth": "$date"},
+                        "month": {"$month": "$date"},
+                        "year": {"$year": "$date"},
+                    },
+                    "dailyDegrees": {"$avg": "$temp"},
+                }
+            },
+            {
+                "$set": {
+                    "dailyDegrees": {
+                        "$cond": [
+                            {"$gt": ["$dailyDegrees", 10]},
+                            {"$add": ["$dailyDegrees", -10]},
+                            0,
+                        ],
+                    }
+                }
+            },
+            {
+                "$group": {
+                    "_id": {
+                        "month": "$_id.month",
+                        "year": "$_id.year",
+                    },
+                    "avgDailyDegrees": {"$avg": "$dailyDegrees"},
+                }
+            },
+        ]
+    )
+    degrees_accumulated = list(degrees_accumulated_agg)
+
+    months = {}
+    for month in degrees_accumulated:
+        value = month.get("avgDailyDegrees")
+        if value is not None:
+            value = round(value, DECIMAL_PLACES)
+        months[str(month.get("_id", {}).get("month"))] = value
+
+    data_count = finca_data.count(
+        count_temp_measures_pipeline(start_datetime, end_datetime)
+    )
+    total_count = (end_datetime - start_datetime).total_seconds() // (60 * 10)
+    data_percentage = get_percent(data_count, total_count)
+
+    summary = DegreesAccumulatedAvgMonthSchema(
+        station=FincaBaseSchema(
+            station_code=finca.station_code,
+            title=finca.title,
+        ),
+        initial_date=start_datetime,
+        final_date=end_datetime,
+        months=months,
+        data_percentage=data_percentage,
+    )
+
+    return summary

+ 98 - 0
backend/routes/calculations/gnral_summary.py

@@ -0,0 +1,98 @@
+from datetime import datetime, timedelta
+from pickletools import read_unicodestring1
+from pprint import pprint
+
+from config.settings import DECIMAL_PLACES
+from cruds.measures import get_finca_measures
+from database.mongo import mongo_measures
+from routes.calculations.pipelines import (
+    count_measures_pipeline,
+    daily_thermal_amplitude_pipeline,
+    deg_daily_acc_avg_pipeline,
+    get_percent,
+    ltgt_pipeline,
+    precip_acc_pipeline,
+)
+from schemas.fincas import FincaBase as FincaBaseSchema
+from schemas.tables import Summary as SummarySchema
+from schemas.tables import SummarySeason as SummarySeasonSchema
+
+
+def ensure_non_empty(result, key):
+    """
+    Ensures that the result list is not empty and that we are not rounding None
+    """
+    if len(result) > 0:
+        value = result[0].get(key)
+        if value is not None:
+            return round(value, DECIMAL_PLACES)
+
+
+def general_summary(
+    finca: FincaBaseSchema,
+    start_datetime: datetime,
+    end_datetime: datetime,
+) -> SummarySchema:
+    finca_data = mongo_measures[finca.station_code]
+
+    lt10 = None
+    gt30 = None
+    gt33 = None
+    deg_acc = None
+    thermal_amplitude = None
+    precip_accumulated = None
+
+    # Compute <10C, >30C and >33C columns
+    ltgt_agg = finca_data.aggregate(ltgt_pipeline(start_datetime, end_datetime))
+    ltgt_agg_result = list(ltgt_agg)
+    if len(ltgt_agg_result) > 0:
+        lt10 = get_percent(
+            ltgt_agg_result[0]["lt10_count"], ltgt_agg_result[0]["count"]
+        )
+        gt30 = get_percent(
+            ltgt_agg_result[0]["gt30_count"], ltgt_agg_result[0]["count"]
+        )
+        gt33 = get_percent(
+            ltgt_agg_result[0]["gt33_count"], ltgt_agg_result[0]["count"]
+        )
+
+    # Compute degrees accumulated column
+    deg_acc_agg = finca_data.aggregate(
+        deg_daily_acc_avg_pipeline(start_datetime, end_datetime)
+    )
+    deg_acc_agg_result = list(deg_acc_agg)
+    deg_acc = ensure_non_empty(deg_acc_agg_result, "sum")
+    deg_acc_avg = ensure_non_empty(deg_acc_agg_result, "avg")
+
+    # Compute thermal amplitude column
+    thermal_amplitude_agg = finca_data.aggregate(
+        daily_thermal_amplitude_pipeline(start_datetime, end_datetime)
+    )
+    thermal_amplitude = ensure_non_empty(list(thermal_amplitude_agg), "avg")
+
+    # Compute accumulated precipitations column
+    precip_accumulated_agg = finca_data.aggregate(
+        precip_acc_pipeline(start_datetime, end_datetime)
+    )
+    precip_accumulated = ensure_non_empty(list(precip_accumulated_agg), "sum")
+
+    data_count = finca_data.count(count_measures_pipeline(start_datetime, end_datetime))
+    total_count = (end_datetime - start_datetime).total_seconds() // (60 * 10)
+    data_percentage = get_percent(data_count, total_count)
+
+    return SummarySchema(
+        station=FincaBaseSchema(
+            station_code=finca.station_code,
+            title=finca.title,
+        ),
+        initial_date=start_datetime,
+        final_date=end_datetime,
+        lt10=lt10,
+        gt30=gt30,
+        gt33=gt33,
+        grados_acumulados=deg_acc,
+        grados_acumulados_promedio=deg_acc_avg,
+        amplitud_termica=thermal_amplitude,
+        precip_acumulada=precip_accumulated,
+        data_percentage=data_percentage,
+    )

+ 65 - 0
backend/routes/calculations/montly_precip.py

@@ -0,0 +1,65 @@
+from datetime import datetime
+from pprint import pprint
+
+from config.settings import DECIMAL_PLACES
+from cruds.measures import get_finca_measures
+from database.mongo import mongo_measures
+from routes.calculations.pipelines import count_precip_measures_pipeline, get_percent
+from schemas.fincas import FincaBase as FincaBaseSchema
+from schemas.tables import MonthlyPrecipitations as MonthlyPrecipitationsSchema
+
+
+def montly_precipitations(
+    finca: FincaBaseSchema, start_datetime: datetime, end_datetime: datetime
+) -> MonthlyPrecipitationsSchema:
+    finca_data = mongo_measures[finca.station_code]
+
+    accumulated_precip_agg = finca_data.aggregate(
+        [
+            {
+                "$match": {
+                    "date": {
+                        "$gte": start_datetime,
+                        "$lte": end_datetime,
+                    },
+                    "precip": {"$ne": None},
+                },
+            },
+            {
+                "$group": {
+                    "_id": {
+                        "month": {"$month": "$date"},
+                        "year": {"$year": "$date"},
+                    },
+                    "sumPrecip": {"$sum": "$precip"},
+                }
+            },
+        ]
+    )
+    accumulated_precip = list(accumulated_precip_agg)
+
+    data_count = finca_data.count(
+        count_precip_measures_pipeline(start_datetime, end_datetime)
+    )
+    total_count = (end_datetime - start_datetime).total_seconds() // (60 * 10)
+    data_percentage = get_percent(data_count, total_count)
+
+    months = {}
+    for month in accumulated_precip:
+        value = month.get("sumPrecip")
+        if value is not None:
+            value = round(value, DECIMAL_PLACES)
+        months[str(month.get("_id", {}).get("month"))] = value
+
+    summary = MonthlyPrecipitationsSchema(
+        station=FincaBaseSchema(
+            station_code=finca.station_code,
+            title=finca.title,
+        ),
+        initial_date=start_datetime,
+        final_date=end_datetime,
+        months=months,
+        data_percentage=data_percentage,
+    )
+
+    return summary

+ 203 - 0
backend/routes/calculations/pipelines.py

@@ -0,0 +1,203 @@
+from config.settings import DECIMAL_PLACES
+
+
+def truncate_100(num):
+    return 100 if num > 100 else num
+
+
+def get_percent(count, total):
+    return truncate_100(round((count * 100) / total, DECIMAL_PLACES))
+
+
+id_daily = {
+    "day": {"$dayOfMonth": "$date"},
+    "month": {"$month": "$date"},
+    "year": {"$year": "$date"},
+}
+
+
+def ltgt_pipeline(start_datetime, end_datetime):
+    return [
+        {
+            "$match": {
+                "date": {
+                    "$gte": start_datetime,
+                    "$lte": end_datetime,
+                },
+                "temp": {
+                    "$ne": None,
+                },
+            },
+        },
+        {
+            "$group": {
+                "_id": None,
+                "lt10_count": {
+                    "$sum": {"$cond": [{"$lt": ["$temp", 10]}, 1, 0]},
+                },
+                "gt30_count": {
+                    "$sum": {"$cond": [{"$gt": ["$temp", 30]}, 1, 0]},
+                },
+                "gt33_count": {
+                    "$sum": {"$cond": [{"$gt": ["$temp", 33]}, 1, 0]},
+                },
+                "count": {
+                    "$sum": 1,
+                },
+            },
+        },
+    ]
+
+
+def deg_daily_acc_avg_pipeline(start_datetime, end_datetime):
+    return [
+        {
+            "$match": {  # Conseguimos las muestras
+                "date": {
+                    "$gte": start_datetime,
+                    "$lte": end_datetime,
+                },
+                "temp": {
+                    "$ne": None,
+                },
+            },
+        },
+        {
+            "$group": {  # Agrupamos por dia y calculamos el promedio de temperatura(de ese dia)
+                "_id": id_daily,
+                "avg": {
+                    "$avg": "$temp",
+                },
+            }
+        },
+        {
+            "$addFields": {  # Pisamos el campo promedio que calculamos antes y lo usamos para los grados dia de ese dia(medio feo)
+                "avg": {
+                    "$cond": [
+                        {"$gt": ["$avg", 10]},
+                        {"$add": ["$avg", -10]  # Cantidad de grados por encima de la temperatura base
+                         },
+                        0,
+                    ],
+                },
+            },
+        },
+        {
+            "$group": {
+                "_id": None,
+                "sum": {  # Grados dias acumulados
+                    "$sum": "$avg",
+                },
+                "avg": {  # grados dias promedio
+                    "$avg": "$avg",
+                },
+            },
+        },
+    ]
+
+
+def daily_thermal_amplitude_pipeline(start_datetime, end_datetime):
+    return [
+        {
+            "$match": {
+                "date": {
+                    "$gte": start_datetime,
+                    "$lte": end_datetime,
+                },
+                "temp": {"$ne": None},
+            },
+        },
+        {
+            "$group": {
+                "_id": id_daily,
+                "min": {
+                    "$min": "$temp",
+                },
+                "max": {
+                    "$max": "$temp",
+                },
+            }
+        },
+        {
+            "$addFields": {
+                "amplitude": {
+                    "$subtract": ["$max", "$min"],
+                },
+            },
+        },
+        {
+            "$group": {
+                "_id": None,
+                "avg": {
+                    "$avg": "$amplitude",
+                },
+            },
+        },
+    ]
+
+
+def precip_acc_pipeline(start_datetime, end_datetime):
+    return [
+        {
+            "$match": {
+                "date": {
+                    "$gte": start_datetime,
+                    "$lte": end_datetime,
+                },
+                "precip": {"$ne": None},
+            },
+        },
+        {
+            "$group": {
+                "_id": None,
+                "sum": {
+                    "$sum": "$precip",
+                },
+            },
+        },
+    ]
+
+
+def count_measures_pipeline(start_datetime, end_datetime) -> dict:
+    return {
+        "date": {
+            "$gte": start_datetime,
+            "$lte": end_datetime,
+        },
+        "$or": [
+            {
+                "temp": {
+                    "$ne": None,
+                },
+            },
+            {
+                "precip": {
+                    "$ne": None,
+                },
+            },
+        ],
+    }
+
+
+def count_temp_measures_pipeline(start_datetime, end_datetime) -> dict:
+    return {
+        "date": {
+            "$gte": start_datetime,
+            "$lte": end_datetime,
+        },
+        "temp": {
+            "$ne": None,
+        },
+    }
+
+
+def count_precip_measures_pipeline(start_datetime, end_datetime) -> dict:
+    return {
+        "date": {
+            "$gte": start_datetime,
+            "$lte": end_datetime,
+        },
+        "precip": {
+            "$ne": None,
+        },
+    }

+ 78 - 0
backend/routes/companies.py

@@ -0,0 +1,78 @@
+# Pydantic imports
+from typing import List
+
+from fastapi import APIRouter, Depends
+
+# Sqlalchemy imports
+from sqlalchemy.orm import Session
+from starlette.responses import Response
+
+from commands.fincas import update_fincas as command_update_fincas
+from cruds.companies import get_companies, get_company_by_id
+
+# My imports
+from cruds.fincas import get_fincas_filter_company
+from database.sqlalchemy import get_db
+from routes.login import get_current_user
+from schemas.company import Company as CompanySchema
+from schemas.fincas import Finca as FincaSchema
+from schemas.users import User as UserSchema
+
+router = APIRouter()
+
+
+@router.get("/company", response_model=CompanySchema)
+async def get_my_company(
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    company = get_company_by_id(db, current_user.company_id)
+    return company
+
+
+"""
+No corresponde ya que un usuario no puede acceder a fincas
+que no pertenezcan a su organizacion, lo dejo por si mas
+adelate hacemos permisos de superusuario o simil
+
+
+@router.get("/companies", response_model=List[CompanySchema])
+async def get_all_companies(
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    companies = get_companies(db)
+    return companies
+
+
+@router.get("/company/{company_id}", response_model=CompanySchema)
+async def get_company(
+    company_id: int,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    company = get_company_by_id(db, company_id)
+    return company
+
+
+@router.get("/company/{company_id}/fincas", response_model=List[FincaSchema])
+async def get_company(
+    company_id: int,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    company = get_fincas_filter_company(db, company_id)
+    return company
+
+
+@router.get(
+    "/company/{company_id}/update-fincas",
+)
+def update_fincas(
+    company_id: int,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    return command_update_fincas(company_id)
+"""

+ 40 - 0
backend/routes/fincas.py

@@ -0,0 +1,40 @@
+# Pydantic imports
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+
+# Sqlalchemy imports
+from sqlalchemy.orm import Session
+from starlette.responses import Response
+
+from commands.fincas import update_fincas as command_update_fincas
+
+# My imports
+from cruds.fincas import get_fincas_filter_company
+from database.sqlalchemy import get_db
+from routes.login import get_current_user
+from schemas.fincas import Finca as FincaSchema
+from schemas.users import User as UserSchema
+
+router = APIRouter()
+
+
+@router.get("/fincas", response_model=List[FincaSchema])
+async def get_all_my_fincas(
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    company = get_fincas_filter_company(db, current_user.company_id)
+    return company
+
+
+@router.get(
+    "/update-fincas",
+)
+def update_my_fincas(
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    return command_update_fincas(current_user.company_id)

+ 148 - 0
backend/routes/login.py

@@ -0,0 +1,148 @@
+# FastApi imports
+# Pydantic imports
+from typing import List
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+
+# passlib imports
+from passlib.hash import sha256_crypt
+
+# Sqlalchemy imports
+from sqlalchemy.orm import Session
+from starlette.responses import Response
+
+from client.loginClient import get_auth_token
+from commands.fincas import update_fincas
+from cruds.companies import create_company, get_company_by_id
+from cruds.users import (
+    create_user,
+    get_user_by_token,
+    get_user_by_username,
+    get_users,
+    update_password,
+)
+from database.sqlalchemy import get_db
+from schemas.company import Company as CompanySchema
+from schemas.users import User as UserSchema
+from schemas.users import UserBase as UserBaseSchema
+
+router = APIRouter()
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
+
+
+async def get_current_user(
+    token: str = Depends(oauth2_scheme),
+    db: Session = Depends(get_db),
+) -> UserSchema:
+    user = get_user_by_token(db, token)
+    if not user:
+        raise HTTPException(
+            status_code=401,
+            detail="No puede iniciar sesión con el token proporcionado.",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return user
+
+
+@router.get("/users", response_model=List[UserBaseSchema])
+async def get_all_users(
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+    users = get_users(db)
+    return users
+
+
+@router.post("/login")
+async def login(
+    form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
+) -> Response:
+
+    # Me logeo en clima
+    response = get_auth_token(form_data.username, form_data.password)
+    if response:
+        if response.status_code == 200:
+
+            # Login correcto, tengo el usuario cacheado?
+            user_sqlite = get_user_by_username(db, username=form_data.username)
+            if user_sqlite:
+
+                # Si si, entonces verifico que las contraseñas sean identicas
+                # De lo contrario, actualizo la cacheada
+
+                if not sha256_crypt.verify(form_data.password, user_sqlite.password):
+                    update_password(db, user_sqlite, form_data.password)
+
+            else:
+                # Verifico si la organizacion a la que pertenece el usuario existe, sino la creo
+
+                company = get_company_by_id(db, response.json()["company_id"])
+                if not company:
+                    company = CompanySchema(
+                        id=response.json()["company_id"],
+                        title=response.json()["company_name"],
+                    )
+                    create_company(db, company)
+
+                # Cacheo el usuario
+
+                new_user = UserSchema(
+                    username=form_data.username,
+                    password=form_data.password,
+                    token=response.json()["token"],
+                    company_id=response.json()["company_id"],
+                )
+
+                user_sqlite = create_user(db=db, user=new_user)
+
+                # Agrego las de la nueva finca estaciones
+                update_fincas(company.id)
+
+            return {"access_token": user_sqlite.token, "token_type": "bearer"}
+
+        elif response.status_code == 400:
+
+            # Credenciales incorrectas para clima, devuelvo el error
+            raise HTTPException(
+                status_code=401, detail=response.json()["non_field_errors"][0]
+            )
+
+        elif response.status_code == 401:
+
+            # Credenciales Correctas pero no tiene acceso al sistema
+            raise HTTPException(status_code=401, detail=response.json()["detail"])
+
+    else:
+
+        # Ante cualquier otro error en clima,
+        # directamente me logeo con las credenciales cacheadas
+
+        user_sqlite = get_user_by_username(db, username=form_data.username)
+        if user_sqlite:
+            if not sha256_crypt.verify(form_data.password, user_sqlite.password):
+                raise HTTPException(
+                    status_code=401,
+                    detail="No puede iniciar sesión con las credenciales proporcionadas.",
+                )
+            else:
+                return {"access_token": user_sqlite.token, "token_type": "bearer"}
+        else:
+            raise HTTPException(
+                status_code=401,
+                detail="No puede iniciar sesión con las credenciales proporcionadas.",
+            )
+
+
+# Por si hay que crear un usuario en el sqlite
+# El nuevo usuario no se sincronizaria en clima
+"""
+@router.post("/users/", response_model=UserSchema)
+def post_user(user: UserSchema, db: Session = Depends(get_db)):
+    db_user = get_user_by_username(db, username=user.username)
+    print(db_user)
+    if db_user:
+        raise HTTPException(status_code=400, detail="Username already registered")
+    return create_user(db=db, user=user)
+"""

+ 211 - 0
backend/routes/measures.py

@@ -0,0 +1,211 @@
+# FastApi imports
+# Pydantic imports
+from datetime import datetime, timedelta
+from typing import List
+
+from fastapi import APIRouter, Depends, HTTPException
+
+# Sqlalchemy imports
+from sqlalchemy.orm import Session
+from starlette.responses import Response
+
+# My imports
+from config.settings import DECIMAL_PLACES
+from cruds.companies import get_company_by_id
+from cruds.fincas import get_finca_by_station_code, get_fincas_filter_company
+from database.sqlalchemy import get_db
+from routes.calculations.degrees_accumulated import monthly_avr_day_degrees
+from routes.calculations.gnral_summary import general_summary
+from routes.calculations.montly_precip import montly_precipitations
+from routes.login import get_current_user
+from schemas.tables import (
+    DegreesAccumulatedAvgMonth as DegreesAccumulatedAvgMonthSchema,
+)
+from schemas.tables import MonthlyPrecipitations as MonthlyPrecipitationsSchema
+from schemas.tables import Summary as SummarySchema
+from schemas.tables import SummarySeason as SummarySeasonSchema
+from schemas.users import User as UserSchema
+
+router = APIRouter()
+
+# Futuro Decorator
+def check_daytimes_order(start_datetime, end_datetime):
+    """Verifico que end_datetime sea despues que start_datetime"""
+    if start_datetime > end_datetime:
+        raise HTTPException(
+            status_code=400,
+            detail="start_datetime es mas grande que end_datetime",
+        )
+    """Verifico que el diff no supere una temporada (240 dias aprox)"""
+    delta = end_datetime - start_datetime
+    if delta.days > 240:
+        raise HTTPException(
+            status_code=400,
+            detail="El rango de fechas pedido supera la cantidad de dias de una temporada",
+        )
+
+
+# Futuro Decorator
+def finca_exists(db, station_code):
+    """Verifico que la estacion exista"""
+    if get_finca_by_station_code(db, station_code) == None:
+        raise HTTPException(
+            status_code=400,
+            detail="ingrese un station_code valido",
+        )
+
+
+def has_finca_access(db, finca, user):
+    if finca in get_fincas_filter_company(db, user.company_id):
+        return True
+    else:
+        raise HTTPException(
+            status_code=401,
+            detail="no tienes acceso a esta finca",
+        )
+
+
+@router.get("/station/all/summary", response_model=List[SummarySchema])
+async def get_fincas_summaries(
+    start_datetime: datetime,
+    end_datetime: datetime,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    check_daytimes_order(start_datetime, end_datetime)
+
+    fincas = get_fincas_filter_company(db, current_user.company_id)
+    response = []
+
+    for finca in fincas:
+        response.append(general_summary(finca, start_datetime, end_datetime))
+
+    return response
+
+
+@router.get("/station/{station_code}/summary", response_model=List[SummarySeasonSchema])
+async def get_finca_summary(
+    start_datetime: datetime,
+    end_datetime: datetime,
+    station_code: str,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    finca_exists(db, station_code)
+
+    check_daytimes_order(start_datetime, end_datetime)
+
+    finca = get_finca_by_station_code(db, station_code)
+
+    has_finca_access(db, finca, current_user)
+
+    response = []
+
+    for i in range(5):
+        new_start_date = start_datetime.replace(year=(start_datetime.year - i))
+        new_end_date = end_datetime.replace(year=(end_datetime.year - i))
+
+        summary = general_summary(finca, new_start_date, new_end_date)
+        # REVIEW: Should write a new from_parent constructor
+        summary_season = SummarySeasonSchema(
+            station=summary.station,
+            initial_date=summary.initial_date,
+            final_date=summary.final_date,
+            lt10=summary.lt10,
+            gt30=summary.gt30,
+            gt33=summary.gt33,
+            grados_acumulados=summary.grados_acumulados,
+            grados_acumulados_promedio=summary.grados_acumulados_promedio,
+            amplitud_termica=summary.amplitud_termica,
+            precip_acumulada=summary.precip_acumulada,
+            data_percentage=summary.data_percentage,
+        )
+
+        if i == 0:
+            summary_season.dias_igualar_temporada = 0
+        else:
+            this_camp_deg_acc = response[0].grados_acumulados
+            this_camp_deg_acc_avg = response[0].grados_acumulados_promedio
+
+            if (
+                this_camp_deg_acc
+                and this_camp_deg_acc_avg
+                and summary_season.grados_acumulados
+            ) is not None:
+                summary_season.dias_igualar_temporada = round(
+                    (summary_season.grados_acumulados - this_camp_deg_acc)
+                    / this_camp_deg_acc_avg,
+                    DECIMAL_PLACES,
+                )
+
+        response.append(summary_season)
+
+    return response
+
+
+@router.get(
+    "/station/{station_code}/monthly-precip",
+    response_model=List[MonthlyPrecipitationsSchema],
+)
+async def get_monthy_precipitations(
+    start_datetime: datetime,
+    end_datetime: datetime,
+    station_code: str,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    finca_exists(db, station_code)
+
+    check_daytimes_order(start_datetime, end_datetime)
+
+    finca = get_finca_by_station_code(db, station_code)
+
+    has_finca_access(db, finca, current_user)
+
+    response = []
+
+    for i in range(5):
+        new_start_date = start_datetime.replace(year=(start_datetime.year - i))
+        new_end_date = end_datetime.replace(year=(end_datetime.year - i))
+        summary = montly_precipitations(finca, new_start_date, new_end_date)
+        print(summary)
+        if summary:
+            response.append(summary)
+
+    return response
+
+
+@router.get(
+    "/station/{station_code}/monthly-degrees",
+    response_model=List[DegreesAccumulatedAvgMonthSchema],
+)
+async def get_monthly_avr_day_degrees(
+    start_datetime: datetime,
+    end_datetime: datetime,
+    station_code: str,
+    db: Session = Depends(get_db),
+    current_user: UserSchema = Depends(get_current_user),
+) -> Response:
+
+    finca_exists(db, station_code)
+
+    check_daytimes_order(start_datetime, end_datetime)
+
+    finca = get_finca_by_station_code(db, station_code)
+
+    has_finca_access(db, finca, current_user)
+
+    response = []
+
+    for i in range(5):
+        new_start_date = start_datetime.replace(year=(start_datetime.year - i))
+        new_end_date = end_datetime.replace(year=(end_datetime.year - i))
+        summary = monthly_avr_day_degrees(finca, new_start_date, new_end_date)
+        print(summary)
+        if summary:
+            response.append(summary)
+
+    return response

+ 0 - 0
backend/schemas/__init__.py


+ 15 - 0
backend/schemas/company.py

@@ -0,0 +1,15 @@
+from typing import List, Optional
+
+from pydantic import BaseModel
+
+from .fincas import Finca as FincaSchema
+from .users import UserBase as UserSchema
+
+
+class Company(BaseModel):
+    id: int
+    title: str
+    users: List[UserSchema] = []
+
+    class Config:
+        orm_mode = True

+ 9 - 0
backend/schemas/finca_company.py

@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+
+class FincaCompany(BaseModel):
+    company_id: int
+    finca_station_code: int
+
+    class Config:
+        orm_mode = True

+ 16 - 0
backend/schemas/fincas.py

@@ -0,0 +1,16 @@
+from datetime import datetime
+from typing import List, Optional
+
+from pydantic import BaseModel
+
+
+class FincaBase(BaseModel):
+    station_code: str
+    title: str
+
+
+class Finca(FincaBase):
+    last_synced: Optional[datetime]
+
+    class Config:
+        orm_mode = True

+ 9 - 0
backend/schemas/measures.py

@@ -0,0 +1,9 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class Measure(BaseModel):
+    temp: float = Field(None)
+    precip: float = Field(None)
+    date: datetime = Field(...)

+ 36 - 0
backend/schemas/tables.py

@@ -0,0 +1,36 @@
+from datetime import datetime
+from typing import Dict, Optional
+
+from pydantic import BaseModel
+
+from .fincas import FincaBase
+
+
+class BaseTable(BaseModel):
+    station: FincaBase
+    initial_date: datetime
+    final_date: datetime
+    data_percentage: float
+
+
+class Summary(BaseTable):
+    lt10: Optional[float]
+    gt30: Optional[float]
+    gt33: Optional[float]
+    grados_acumulados: Optional[float]
+    grados_acumulados_promedio: Optional[float]
+    amplitud_termica: Optional[float]
+    precip_acumulada: Optional[float]
+
+
+class SummarySeason(Summary):
+    dias_igualar_temporada: Optional[float]
+
+
+class MonthlyPrecipitations(BaseTable):
+    months: Dict[int, float]
+    precip_acumulada: Optional[float]
+
+
+class DegreesAccumulatedAvgMonth(BaseTable):
+    months: Dict[int, float]

+ 17 - 0
backend/schemas/users.py

@@ -0,0 +1,17 @@
+from pydantic import BaseModel
+
+
+class UserBase(BaseModel):
+    username: str
+    company_id: int
+
+    class Config:
+        orm_mode = True
+
+
+class User(UserBase):
+    password: str
+    token: str
+
+    class Config:
+        orm_mode = True

+ 77 - 0
docker-compose.yml

@@ -0,0 +1,77 @@
+version: '3.4'
+
+services:
+
+  app-backend:
+     restart: always
+     container_name: vendimia-backend
+     build: 
+      context: ./backend
+      dockerfile: ./Dockerfile
+     env_file: 
+      - ./.env
+     volumes:
+       - ./backend:/app
+     ports: 
+       - "8000:8000"  
+     extra_hosts:
+       - "host.docker.internal:host-gateway"
+     depends_on:
+       - postgres
+       - mongo
+      
+
+  app-frontend:
+    restart: always
+    container_name: vendimia-frontend
+    build: 
+      context: ./frontend
+      dockerfile: ./Dockerfile
+    env_file:
+        - ./.env
+    ports:
+      - "3000:3000"
+    volumes:
+      - ./frontend:/app
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+
+  mongo:
+    image: mongo
+    restart: always
+    volumes:
+      - ./mongo_data:/data/db
+    ports:
+      - 9019:27017
+
+  postgres:
+    image: postgres
+    env_file:
+      - postgres.env
+    volumes:
+      - ./postgres_data:/var/lib/postgresql/data/
+
+  # mongoweb:
+  #   image: mongoclient/mongoclient
+  #   depends_on:
+  #     - mongo
+  #   environment:
+  #     - MONGO_URL="mongodb://mongo:27017/"
+  #   ports:
+  #     - 9005:3000
+
+  mongoexpress:
+    image: mongo-express 
+    depends_on:
+      - mongo
+    environment:
+      - ME_CONFIG_MONGODB_SERVER:"mongo"
+    ports:
+      - 9005:8081
+    
+  admindb:
+    image: adminer
+    depends_on:
+      - postgres
+    ports:
+      - 9003:8080

+ 3 - 0
frontend/.dockerignore

@@ -0,0 +1,3 @@
+**/node_modules
+**/npm-debug.log
+build

+ 9 - 0
frontend/Dockerfile

@@ -0,0 +1,9 @@
+FROM node:14-alpine as development
+
+COPY . /app
+WORKDIR /app
+
+RUN npm install
+
+EXPOSE 3000
+CMD ["npm", "start"]

+ 31 - 0
frontend/Dockerfile.production

@@ -0,0 +1,31 @@
+
+# Esta imagen es multi-stage, es decir,
+# se buildea en un contenedor transitorio, y luego se hace otro contenedor que sirva la app.
+# Esto para que la imagen sea más liviana y no incluya nada más que el build de producción y nginx
+
+FROM node:14-alpine AS builder
+ARG REACT_APP_API_URL
+ENV NODE_ENV production
+# Copy app files
+COPY . /app
+# Add a work directory
+WORKDIR /app
+
+# COPY package-lock.json .
+RUN npm install --production
+
+# Build the app
+ENV REACT_APP_API_URL=${REACT_APP_API_URL}
+RUN npm run build
+
+# Bundle static assets with nginx
+FROM nginx:1.21.0-alpine as production
+ENV NODE_ENV production
+# Copy built assets from builder
+COPY --from=builder /app/build /usr/share/nginx/html
+# Add your nginx.conf
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+# Expose port
+EXPOSE 80
+# Start nginx
+CMD ["nginx", "-g", "daemon off;"]

+ 9 - 0
frontend/nginx.conf

@@ -0,0 +1,9 @@
+server {
+  listen 80;
+
+  location / {
+    root /usr/share/nginx/html/;
+    include /etc/nginx/mime.types;
+    try_files $uri $uri/ /index.html;
+  }
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 20091 - 0
frontend/package-lock.json


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 35888 - 0
frontend/package-lockdaold.json


+ 54 - 0
frontend/package.json

@@ -0,0 +1,54 @@
+{
+  "name": "vendimia-frontend",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@popperjs/core": "^2.10.2",
+    "@testing-library/jest-dom": "^5.11.4",
+    "@testing-library/react": "^11.1.0",
+    "@testing-library/user-event": "^12.1.10",
+    "@types/bootstrap": "^5.1.6",
+    "@types/chroma-js": "^2.1.3",
+    "@types/jest": "^26.0.15",
+    "@types/node": "^12.0.0",
+    "@types/react": "^17.0.0",
+    "@types/react-dom": "^17.0.0",
+    "@types/react-router-dom": "^5.3.1",
+    "bootstrap": "^5.1.1",
+    "bootstrap-icons": "^1.5.0",
+    "chroma-js": "^2.1.2",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "react-router-dom": "^5.3.0",
+    "react-scripts": "4.0.3",
+    "typescript": "^4.1.2",
+    "web-vitals": "^1.0.1"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test",
+    "eject": "react-scripts eject"
+  },
+  "eslintConfig": {
+    "extends": [
+      "react-app",
+      "react-app/jest"
+    ]
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "devDependencies": {
+    "react-error-overlay": "^6.0.9"
+  }
+}

BIN
frontend/public/favicon.ico


+ 38 - 0
frontend/public/index.html

@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <meta
+      name="description"
+      content="Web site created using create-react-app"
+    />
+    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>Vendimia | by Omixom</title>
+  </head>
+  <body>
+    <noscript
+      >Necesitas tener javascript activado para usar esta aplicacion.</noscript
+    >
+    <div id="root"></div>
+    <aside class="modal" id="page-modal">
+      <div class="modal-dialog-centered modal-dialog" id="modal-portal"></div>
+    </aside>
+  </body>
+</html>

BIN
frontend/public/logo192.png


BIN
frontend/public/logo512.png


+ 25 - 0
frontend/public/manifest.json

@@ -0,0 +1,25 @@
+{
+  "short_name": "React App",
+  "name": "Create React App Sample",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    },
+    {
+      "src": "logo192.png",
+      "type": "image/png",
+      "sizes": "192x192"
+    },
+    {
+      "src": "logo512.png",
+      "type": "image/png",
+      "sizes": "512x512"
+    }
+  ],
+  "start_url": ".",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}

+ 3 - 0
frontend/public/robots.txt

@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:

+ 9 - 0
frontend/src/App.test.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+  render(<App />);
+  const linkElement = screen.getByText(/learn react/i);
+  expect(linkElement).toBeInTheDocument();
+});

+ 27 - 0
frontend/src/App.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
+import Home from "./components/pages/Home";
+import Login from "./components/pages/Login";
+import AuthProvider from "./context/auth/AuthProvider";
+import ErrorBoundary from "./components/hoc/ErrorBoundary";
+
+function App() {
+  return (
+    <ErrorBoundary>
+      <Router>
+        <AuthProvider>
+          <Switch>
+            <Route exact path="/">
+              <Home />
+            </Route>
+            <Route exact path="/login">
+              <Login />
+            </Route>
+          </Switch>
+        </AuthProvider>
+      </Router>
+    </ErrorBoundary>
+  );
+}
+
+export default App;

+ 20 - 0
frontend/src/api/auth.ts

@@ -0,0 +1,20 @@
+import { apiURL } from "../config";
+
+export function login(username: string, password: string) {
+  const headers = new Headers();
+  headers.append("Content-Type", "application/x-www-form-urlencoded");
+  headers.append("Access-Control-Allow-Origin", "*");
+
+  const urlencoded = new URLSearchParams();
+  urlencoded.append("username", username);
+  urlencoded.append("password", password);
+
+  const requestOptions: RequestInit = {
+    method: "POST",
+    headers: headers,
+    body: urlencoded,
+    mode: "cors",
+    redirect: "follow",
+  };
+  return fetch(`${apiURL}login`, requestOptions);
+}

+ 2 - 0
frontend/src/api/index.ts

@@ -0,0 +1,2 @@
+export * from "./auth";
+export * from "./tables";

+ 172 - 0
frontend/src/api/mocks.ts

@@ -0,0 +1,172 @@
+// Utilidad para simular requests durante desarrollo
+export const mockRequest = (
+  success: Boolean,
+  payload: string,
+  timeout: number | null
+): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      if (success) {
+        resolve(payload);
+      } else {
+        reject(payload);
+      }
+    }, timeout || 1000);
+  });
+};
+
+export const mockPayloads = {
+  seasonsSummariesTable: `
+    [
+      {
+        "station": {
+          "name": "excepteur",
+          "code": "6dd49d1c-68ee-4e68-b993-0ca6489c478c"
+        },
+        "from": "2016-12-22T02:14:38 +03:00",
+        "to": "2015-12-02T10:22:04 +03:00",
+        "lt10": 30.48,
+        "gt30": 13.65,
+        "gt33": 21.98,
+        "grados_acumulados": 18.67,
+        "amplitud_termica": 19.95,
+        "precip_acumulada": 11.53,
+        "dias_para_igualar_temporada": -8
+      },
+      {
+        "station": {
+          "name": "excepteur",
+          "code": "6dd49d1c-68ee-4e68-b993-0ca6489c478c"
+        },
+        "from": "2014-08-02T04:29:26 +03:00",
+        "to": "2014-06-18T08:59:25 +03:00",
+        "lt10": 30.8,
+        "gt30": 15.51,
+        "gt33": 27.18,
+        "grados_acumulados": 15.31,
+        "amplitud_termica": 13.78,
+        "precip_acumulada": 15.58,
+        "dias_para_igualar_temporada": -2
+      },
+      {
+        "station": {
+          "name": "excepteur",
+          "code": "6dd49d1c-68ee-4e68-b993-0ca6489c478c"
+        },
+        "from": "2017-04-30T08:01:55 +03:00",
+        "to": "2021-05-03T05:35:16 +03:00",
+        "lt10": 19.58,
+        "gt30": 29.96,
+        "gt33": 17.12,
+        "grados_acumulados": 13.11,
+        "amplitud_termica": 31.6,
+        "precip_acumulada": 23.31,
+        "dias_para_igualar_temporada": -10
+      },
+      {
+        "station": {
+          "name": "excepteur",
+          "code": "6dd49d1c-68ee-4e68-b993-0ca6489c478c"
+        },
+        "from": "2016-07-28T06:30:57 +03:00",
+        "to": "2015-04-29T02:19:31 +03:00",
+        "lt10": 34.5,
+        "gt30": 24.31,
+        "gt33": 38.21,
+        "grados_acumulados": 18.47,
+        "amplitud_termica": 10.49,
+        "precip_acumulada": 24.36,
+        "dias_para_igualar_temporada": 7
+      },
+      {
+        "station": {
+          "name": "excepteur",
+          "code": "6dd49d1c-68ee-4e68-b993-0ca6489c478c"
+        },
+        "from": "2019-08-21T08:41:01 +03:00",
+        "to": "2014-07-05T03:50:04 +03:00",
+        "lt10": 12.94,
+        "gt30": 16.74,
+        "gt33": 21.02,
+        "grados_acumulados": 37.22,
+        "amplitud_termica": 31.71,
+        "precip_acumulada": 10.77,
+        "dias_para_igualar_temporada": -4
+      }
+    ]
+  `,
+  general: `
+    [
+      {
+        "station": {
+          "name": "consequat",
+          "code": "b7d147cb-a5f0-4a55-b6d9-8934b7085f13"
+        },
+        "from": "2015-02-15T02:03:07 +03:00",
+        "to": "2018-05-19T10:42:45 +03:00",
+        "lt10": 23.81,
+        "gt30": 30.49,
+        "gt33": 25.3,
+        "grados_acumulados": 20.88,
+        "amplitud_termica": 36,
+        "precip_acumulada": 21.64
+      },
+      {
+        "station": {
+          "name": "dolor",
+          "code": "b3414719-92ad-4977-985b-e9e6f5d09312"
+        },
+        "from": "2014-03-22T06:51:34 +03:00",
+        "to": "2018-01-27T07:58:57 +03:00",
+        "lt10": 37.58,
+        "gt30": 12.2,
+        "gt33": 32.12,
+        "grados_acumulados": 31.02,
+        "amplitud_termica": 39.29,
+        "precip_acumulada": 12.01
+      },
+      {
+        "station": {
+          "name": "dolore",
+          "code": "889d2e8b-45fd-4342-88ae-1de44b58409c"
+        },
+        "from": "2020-09-27T02:05:22 +03:00",
+        "to": "2021-09-16T10:24:50 +03:00",
+        "lt10": 32.2,
+        "gt30": 30.93,
+        "gt33": 32.54,
+        "grados_acumulados": 26.78,
+        "amplitud_termica": 24.5,
+        "precip_acumulada": 21.59
+      },
+      {
+        "station": {
+          "name": "eu",
+          "code": "62fada45-6f04-40b4-95a1-99959b82145c"
+        },
+        "from": "2019-02-08T12:46:41 +03:00",
+        "to": "2021-06-01T07:29:53 +03:00",
+        "lt10": 14.25,
+        "gt30": 10.34,
+        "gt33": 30.31,
+        "grados_acumulados": 29,
+        "amplitud_termica": 25.09,
+        "precip_acumulada": 33.63
+      },
+      {
+        "station": {
+          "name": "officia",
+          "code": "6cca9007-ea5b-496b-9e7b-a520416a6564"
+        },
+        "from": "2018-06-26T08:18:13 +03:00",
+        "to": "2020-03-27T09:04:18 +03:00",
+        "lt10": 38.79,
+        "gt30": 34.99,
+        "gt33": 36.03,
+        "grados_acumulados": 18.87,
+        "amplitud_termica": 26.55,
+        "precip_acumulada": 30.38
+      }
+    ]
+  `,
+};

+ 1 - 0
frontend/src/api/shared.ts

@@ -0,0 +1 @@
+export default {};

+ 134 - 0
frontend/src/api/tables.ts

@@ -0,0 +1,134 @@
+import { apiURL } from "../config";
+import { Summary, Station, DegreeDays } from "../types";
+import { Precipitations } from "../types/precipitations";
+// Quizas llamarlos table no hacia falta,
+// pero es preferible antes de que a alguien se le olvide usar un alias cuando importe estas funciones.
+
+// Wrapper para que si la request sale mal tengamos una rejection
+// en lugar de que se resuelva y tengamos que chequar el status
+const mustFetchJson = <T>(url: string, config: RequestInit) =>
+  new Promise<T>(async (resolve, reject) => {
+    const res = await fetch(url, config);
+    if (!res.ok) {
+      const reason = await res.text();
+      reject(reason);
+    }
+
+    try {
+      const body = await res.json();
+      resolve(body);
+    } catch (err) {
+      reject(err);
+    }
+  });
+
+export const monthlyDegrees = (
+  from: string,
+  to: string,
+  sector: string,
+  token: string
+) => {
+  const headers = new Headers();
+  headers.append("Access-Control-Allow-Origin", "*");
+  headers.append("Authorization", `Bearer ${token}`);
+
+  const config: RequestInit = {
+    method: "GET",
+    mode: "cors",
+    headers,
+  };
+
+  const qParams = new URLSearchParams();
+  qParams.append("start_datetime", `${from}T00:00`);
+  qParams.append("end_datetime", `${to}T00:00`);
+
+  return mustFetchJson<DegreeDays[]>(
+    `${apiURL}station/${sector}/monthly-degrees?${qParams.toString()}`,
+    config
+  );
+};
+
+export const monthlyPrecip = (
+  from: string,
+  to: string,
+  sector: string,
+  token: string
+) => {
+  const headers = new Headers();
+  headers.append("Access-Control-Allow-Origin", "*");
+  headers.append("Authorization", `Bearer ${token}`);
+
+  const config: RequestInit = {
+    method: "GET",
+    mode: "cors",
+    headers,
+  };
+
+  const qParams = new URLSearchParams();
+  qParams.append("start_datetime", `${from}T00:00`);
+  qParams.append("end_datetime", `${to}T00:00`);
+
+  return mustFetchJson<Precipitations[]>(
+    `${apiURL}station/${sector}/monthly-precip?${qParams.toString()}`,
+    config
+  );
+};
+
+export const generalTable = (from: string, to: string, token: string) => {
+  const headers = new Headers();
+  headers.append("Access-Control-Allow-Origin", "*");
+  headers.append("Authorization", `Bearer ${token}`);
+
+  const config: RequestInit = {
+    method: "GET",
+    mode: "cors",
+    headers,
+  };
+
+  const qParams = new URLSearchParams();
+  qParams.append("start_datetime", `${from}T00:00`);
+  qParams.append("end_datetime", `${to}T00:00`);
+  return mustFetchJson<Summary[]>(
+    `${apiURL}station/all/summary?${qParams.toString()}`,
+    config
+  );
+};
+
+export const seasonsSummariesTable = (
+  from: string,
+  to: string,
+  sector: string,
+  token: string
+) => {
+  const headers = new Headers();
+  headers.append("Access-Control-Allow-Origin", "*");
+  headers.append("Authorization", `Bearer ${token}`);
+
+  const qParams = new URLSearchParams();
+  qParams.append("start_datetime", `${from}T00:00`);
+  qParams.append("end_datetime", `${to}T00:00`);
+
+  const config: RequestInit = {
+    method: "GET",
+    mode: "cors",
+    headers,
+  };
+
+  return mustFetchJson<Summary[]>(
+    `${apiURL}station/${sector}/summary?${qParams.toString()}`,
+    config
+  );
+};
+
+export const sectors = (token: string) => {
+  const headers = new Headers();
+  headers.append("Access-Control-Allow-Origin", "*");
+  headers.append("Authorization", `Bearer ${token}`);
+
+  const config: RequestInit = {
+    method: "GET",
+    mode: "cors",
+    headers,
+  };
+  return mustFetchJson<Station[]>(`${apiURL}fincas`, config);
+};

+ 54 - 0
frontend/src/components/UI/dashboard/checkboxTables/index.tsx

@@ -0,0 +1,54 @@
+import React, { FC } from "react";
+
+interface checkProps {
+  changeSeason: any;
+  changeDegree: any;
+  changePrep: any;
+}
+
+const CheckboxTables: FC<checkProps> = ({
+  changeSeason,
+  changeDegree,
+  changePrep,
+}) => {
+  return (
+    <section className="row p-lg-4 p-md-3 p-2">
+      <div className="col-8" role="group">
+        <input
+          type="checkbox"
+          className="form-check-input me-2"
+          onClick={changeSeason}
+          id="btncheckSeason"
+          autoComplete="off"
+        />
+        <label className="form-check-label me-3" htmlFor="btncheckSeason">
+          General
+        </label>
+
+        <input
+          type="checkbox"
+          className="form-check-input me-2"
+          onClick={changePrep}
+          id="btncheckPre"
+          autoComplete="off"
+        />
+        <label className="form-check-label me-3" htmlFor="btncheckPre">
+          Precipitaciones
+        </label>
+
+        <input
+          type="checkbox"
+          className="form-check-input me-2"
+          onClick={changeDegree}
+          id="btncheckDegree"
+          autoComplete="off"
+        />
+        <label className="form-check-label me-3" htmlFor="btncheckDegree">
+          Grados día
+        </label>
+      </div>
+    </section>
+  );
+};
+
+export default CheckboxTables;

+ 159 - 0
frontend/src/components/UI/dashboard/cockpit/index.tsx

@@ -0,0 +1,159 @@
+import React, {
+  useContext,
+  useState,
+  useEffect,
+  ChangeEvent,
+  FC,
+  useMemo,
+} from "react";
+import Select from "../../forms/select";
+import CalendarInput from "../../forms/calendarInput";
+import {
+  DispatchContext,
+  StateContext,
+} from "../../../../context/dashboard/Provider";
+import * as actions from "../../../../context/dashboard/actions";
+import { sectors } from "../../../../api";
+import { UserStateContext } from "../../../../context/auth/AuthProvider";
+import { Station } from "../../../../types";
+import {
+  campMaxMin,
+  campString,
+  getCampList,
+  mustCheckDateOrder,
+} from "../../../../utils";
+import Modal from "../../../portals/modal";
+
+const Cockpit: FC = () => {
+  const [error, setError] = useState<string | null>(null);
+
+  const dashboardState = useContext(StateContext);
+  const dashboardDispatch = useContext(DispatchContext);
+  const userState = useContext(UserStateContext);
+  const [sectorList, setSectorList] = useState<Station[]>([]);
+
+  // Opciones para selector de campania
+  const selectedCampaignYear = dashboardState.year;
+  const campaignList = dashboardState.years.map((v, _) => ({
+    title: v,
+    value: v,
+  }));
+
+  // Inicializacion del selector de fincas
+  useEffect(() => {
+    const token = userState.userToken;
+    if (!token) return;
+    sectors(token).then((list) => {
+      setSectorList(list);
+      if (list.length > 0) {
+        dashboardDispatch(actions.setSector(list[0].station_code));
+      }
+    });
+  }, []);
+
+  const sectorChoices = useMemo(
+    () =>
+      sectorList.map(({ title, station_code }) => ({
+        title,
+        value: station_code,
+      })),
+    [sectorList]
+  );
+
+  const handleCampChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const camp = campMaxMin(e.target.value);
+    dashboardDispatch(actions.setMinMaxDate(camp.min, camp.max));
+    dashboardDispatch(actions.setYearControl(e.target.value));
+  };
+
+  const handleFromChange = (e: ChangeEvent<HTMLInputElement>) => {
+    try {
+      mustCheckDateOrder(e.target.value, dashboardState.to);
+    } catch (e) {
+      setError(e as any);
+      return;
+    }
+    return dashboardDispatch(actions.setFromControl(e.target.value));
+  };
+
+  const handleToChange = (e: ChangeEvent<HTMLInputElement>) => {
+    try {
+      mustCheckDateOrder(dashboardState.from, e.target.value);
+    } catch (e) {
+      setError(e as any);
+      return;
+    }
+    return dashboardDispatch(actions.setToControl(e.target.value));
+  };
+
+  return (
+    <>
+      {error && (
+        <Modal
+          key="Error"
+          title={"Error"}
+          staticBackdrop
+          accept={() => setError(null)}
+        >
+          <p style={{ color: "red" }}>{error.toString()}</p>
+        </Modal>
+      )}
+      <section className="row mb-3">
+        {/* Finca */}
+        <div className="col-12 col-lg-4 mb-2 col-xl-3 mb-xl-0">
+          <Select
+            labelTitle="Finca"
+            list={sectorChoices}
+            onChange={(e: ChangeEvent<HTMLInputElement>) =>
+              dashboardDispatch(actions.setSector(e.target.value))
+            }
+            name="finca"
+            placeholder="Fincas"
+          />
+        </div>
+
+        {/* Campania */}
+        <div className="col-6 col-lg-4 mb-2 col-xl-auto mb-xl-0">
+          <Select
+            list={campaignList}
+            onChange={handleCampChange}
+            value={selectedCampaignYear}
+            name="campania"
+            labelTitle="Campaña"
+            placeholder="Camapaña"
+          />
+        </div>
+
+        {/* Desde */}
+        <div className="col-6 col-lg-4 mb-2 col-xl-auto mb-xl-0">
+          <CalendarInput
+            onChange={handleFromChange}
+            value={dashboardState.from}
+            min={dashboardState.minDate}
+            max={dashboardState.maxDate}
+            name="desde"
+            labelTitle="Fecha desde"
+          />
+        </div>
+
+        {/* Hasta */}
+        <div className="col-6 col-lg-4 mb-2 col-xl-auto mb-xl-0">
+          <CalendarInput
+            onChange={handleToChange}
+            value={dashboardState.to}
+            min={dashboardState.minDate}
+            max={dashboardState.maxDate}
+            name="hasta"
+            labelTitle="Fecha hasta"
+          />
+        </div>
+      </section>
+      <h5>
+        Temporada {parseInt(dashboardState.year) - 1} - {dashboardState.year} -
+        Vendimia {parseInt(dashboardState.year)}
+      </h5>
+    </>
+  );
+};
+
+export default Cockpit;

+ 52 - 0
frontend/src/components/UI/dashboard/detail/index.tsx

@@ -0,0 +1,52 @@
+import React, { FC, useState, useContext } from "react";
+import { StateContext as DashboardContext } from "../../../../context/dashboard/Provider";
+import CheckboxTables from "../checkboxTables";
+import GeneralPerSeason from "../../../data/GeneralPerSeason";
+import Precipitation from "../../../data/Precipitation";
+import DegreeDay from "../../../data/DegreeDay";
+
+const Detail: FC = () => {
+  const [viewSeasonState, setViewSeasonState] = useState<Boolean>(false);
+  const [viewDegreeState, setViewDegreeState] = useState<Boolean>(false);
+  const [viewPrepState, setViewPrepState] = useState<Boolean>(false);
+  const toggleSeason = () => setViewSeasonState(!viewSeasonState);
+  const toggleDegree = () => setViewDegreeState(!viewDegreeState);
+  const togglePrep = () => setViewPrepState(!viewPrepState);
+  const dashboardState = useContext(DashboardContext);
+
+  return (
+    <>
+      <h4>Gráficos históricos de finca</h4>
+
+      <CheckboxTables
+        changeSeason={toggleSeason}
+        changeDegree={toggleDegree}
+        changePrep={togglePrep}
+      />
+
+      {viewSeasonState ? (
+        <section className="row my-4">
+          <div className="col-xl-10">
+            <GeneralPerSeason />
+          </div>
+        </section>
+      ) : null}
+      {viewPrepState ? (
+        <section className="row my-4">
+          <div className="col-xl-10">
+            <Precipitation />
+          </div>
+        </section>
+      ) : null}
+      {viewDegreeState ? (
+        <section className="row my-4">
+          <div className="col-xl-10">
+            <DegreeDay />
+          </div>
+        </section>
+      ) : null}
+    </>
+  );
+};
+
+export default Detail;

+ 29 - 0
frontend/src/components/UI/forms/calendarInput.tsx

@@ -0,0 +1,29 @@
+import React, { FC } from "react";
+
+interface calendarInputProps {
+  onChange: Function;
+  name: string;
+  value?: string | null;
+  min?: string;
+  max?: string;
+  [index: string]: any;
+}
+
+const CalendarInput: FC<calendarInputProps> = ({ className, ...props }) => {
+  return (
+    <>
+      <label htmlFor={props.name} className="form-label">
+        {props.labelTitle}
+      </label>
+      <input
+        {...(props as any)}
+        // defaultValue={defaultValue}
+        className={`form-control ${className}`}
+        type="date"
+        id="start"
+      />
+    </>
+  );
+};
+
+export default CalendarInput;

+ 35 - 0
frontend/src/components/UI/forms/select.tsx

@@ -0,0 +1,35 @@
+import React, { FC } from "react";
+
+interface selectProps {
+  list: any;
+  onChange: Function;
+  placeholder: string;
+  name: string;
+  [index: string]: any;
+}
+
+// En las props hago un "rest", que me da el resto del objeto que estoy desestructurando
+const Select: FC<selectProps> = ({ list, className, ...props }) => {
+  return (
+    <>
+      <label htmlFor={props.name} className="form-label">
+        {props.labelTitle}
+      </label>
+      <select {...(props as any)} className={`form-select ${className}`}>
+        {props.placeholder && !list.length ? (
+          <option key="placeholder" value="">
+            {props.placeholder}
+          </option>
+        ) : null}
+
+        {list?.map((item: any) => (
+          <option value={item.value} key={item.value}>
+            {item.title}
+          </option>
+        ))}
+      </select>
+    </>
+  );
+};
+
+export default Select;

+ 88 - 0
frontend/src/components/data/DegreeDay/index.tsx

@@ -0,0 +1,88 @@
+import React, { FC, useState, useEffect, useContext } from "react";
+import { monthlyDegrees } from "../../../api";
+import { UserStateContext } from "../../../context/auth/AuthProvider";
+import { StateContext } from "../../../context/dashboard/Provider";
+import { DegreeDays } from "../../../types";
+import * as classes from "../tables.module.css";
+import { campString, greenScale } from "../../../utils";
+
+interface DegreesProps {}
+
+const colorScale = greenScale([0, 60]);
+const ResultTd = ({ value }: any) => (
+  <td
+    className={classes.cell}
+    style={{ backgroundColor: (colorScale(value) as any).css() as any }}
+  >
+    {value ?? "-"}
+  </td>
+);
+
+const DegreeDay: FC<DegreesProps> = () => {
+  const [data, setData] = useState<DegreeDays[] | null>(null);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const { userToken } = useContext(UserStateContext);
+  const { from, to, sector } = useContext(StateContext);
+
+  useEffect(() => {
+    if (!userToken || !sector) return;
+    setIsLoading(true);
+    monthlyDegrees(from, to, sector, userToken).then((res) => {
+      setData(res);
+      setIsLoading(false);
+    });
+  }, [from, to, userToken, sector]);
+
+  const rows = data?.map((x) => (
+    <tr key={x.initial_date}>
+      <th className={classes.cell}>
+        {campString(x.initial_date, x.final_date)}
+      </th>
+      <ResultTd value={x.months[10]} />
+      <ResultTd value={x.months[11]} />
+      <ResultTd value={x.months[12]} />
+      <ResultTd value={x.months[1]} />
+      <ResultTd value={x.months[2]} />
+      <ResultTd value={x.months[3]} />
+      <td className={classes.cell}>
+        {x.data_percentage ? `${x.data_percentage}%` : "-"}
+      </td>
+    </tr>
+  ));
+
+  return (
+    <section>
+      <h5>
+        Grados días promedio mensuales - {data ? data[0].station.title : null}
+      </h5>
+      <table className={`${classes.table}`}>
+        <thead>
+          <th className={`${classes.cell} ${classes.dateCell}`}>Temporada</th>
+          <td className={classes.cell}>Octubre</td>
+          <td className={classes.cell}>Noviembre</td>
+          <td className={classes.cell}>Diciembre</td>
+          <td className={classes.cell}>Enero</td>
+          <td className={classes.cell}>Febrero</td>
+          <td className={classes.cell}>Marzo</td>
+          <td className={classes.cell}>Porcentaje de datos</td>
+        </thead>
+        <tbody>{data && !isLoading && rows}</tbody>
+      </table>
+      {isLoading && (
+        <div className="d-flex py-3 justify-content-center">
+          <div className="spinner-border" role="status">
+            <span className="visually-hidden">Loading...</span>
+          </div>
+        </div>
+      )}
+      {data && data.length === 0 && (
+        <div className="d-flex py-3 justify-content-center">
+          <div role="status">
+            <p>Sin datos</p>
+          </div>
+        </div>
+      )}
+    </section>
+  );
+};
+export default DegreeDay;

+ 102 - 0
frontend/src/components/data/GeneralPerSeason/index.tsx

@@ -0,0 +1,102 @@
+import React, { FC, useEffect, useContext, useState } from "react";
+import { seasonsSummariesTable } from "../../../api/tables";
+import { Summary } from "../../../types/summary";
+import { TableHeader as Header, TdGroup } from "../shared";
+import * as classes from "../tables.module.css";
+import { StateContext as DashboardContext } from "../../../context/dashboard/Provider";
+import { UserStateContext } from "../../../context/auth/AuthProvider";
+import { campString, grayScale, greenScale, yellowScale } from "../../../utils";
+import { redScale, orangeScale, blueScale } from "../../../utils";
+
+const TemperaturePerSeason: FC = () => {
+  const [data, setData] = useState<Summary[] | null>(null);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const { sector, from, to } = useContext(DashboardContext);
+  const { userToken } = useContext(UserStateContext);
+
+  useEffect(() => {
+    if (!sector || !userToken) return;
+    setIsLoading(true);
+    seasonsSummariesTable(from, to, sector, userToken).then((res) => {
+      setData(res);
+      setIsLoading(false);
+    });
+  }, [from, to, sector, userToken]);
+
+  const rows = data?.map((x) => (
+    <tr key={x.initial_date}>
+      <th className={`${classes.cell} ${classes.nowrap}`}>
+        {campString(x.initial_date, x.final_date)}
+      </th>
+      <TdGroup>
+        <td
+          style={{ backgroundColor: blueScale([0, 50])(x.lt10).css() as any }}
+        >
+          {x.lt10 ?? "-"}%
+        </td>
+        <td
+          style={{ backgroundColor: orangeScale([0, 50])(x.gt33).css() as any }}
+        >
+          {x.gt30 ?? "-"}%
+        </td>
+        <td style={{ backgroundColor: redScale([0, 50])(x.gt33).css() as any }}>
+          {x.gt33 ?? "-"}%
+        </td>
+      </TdGroup>
+
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: greenScale([0, 3000])(x.grados_acumulados).css(),
+        }}
+      >
+        {x.grados_acumulados ?? "-"}
+      </td>
+      <td className={classes.cell}>{x.dias_igualar_temporada ?? "-"}</td>
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: yellowScale([0, 50])(x.amplitud_termica).css(),
+        }}
+      >
+        {x.amplitud_termica ?? "-"}
+      </td>
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: grayScale([0, 300])(x.precip_acumulada).css(),
+        }}
+      >
+        {x.precip_acumulada ?? "-"}
+      </td>
+      <td className={classes.cell}>
+        {x.data_percentage ? `${x.data_percentage}%` : "-"}
+      </td>
+    </tr>
+  ));
+
+  return (
+    <>
+      <h5>General - {data ? data[0].station.title : null}</h5>
+      <table className={`${classes.table} ${classes.widthSix}`}>
+        <Header daysToMatchCurrentTemperature={true} />
+        <tbody>{data && !isLoading && rows}</tbody>
+      </table>
+      {isLoading && (
+        <div className="d-flex py-3 justify-content-center">
+          <div className="spinner-border" role="status">
+            <span className="visually-hidden">Loading...</span>
+          </div>
+        </div>
+      )}
+      {data && data.length === 0 && (
+        <div className="d-flex py-3 justify-content-center">
+          <div role="status">
+            <p>Sin datos</p>
+          </div>
+        </div>
+      )}
+    </>
+  );
+};
+export default TemperaturePerSeason;

+ 119 - 0
frontend/src/components/data/GeneralPerSector/index.tsx

@@ -0,0 +1,119 @@
+import React, { useState, useEffect, FC, useContext } from "react";
+import { generalTable } from "../../../api/tables";
+import { UserStateContext } from "../../../context/auth/AuthProvider";
+import { StateContext } from "../../../context/dashboard/Provider";
+import { Summary } from "../../../types/summary";
+import { TableHeader as Header, TdGroup } from "../shared";
+import * as classes from "../tables.module.css";
+import {
+  redScale,
+  orangeScale,
+  blueScale,
+  greenScale,
+  yellowScale,
+  grayScale,
+} from "../../../utils";
+
+const GeneralPerSector: FC = () => {
+  const [data, setData] = useState<Summary[] | null>(null);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const { userToken } = useContext(UserStateContext);
+  const { from, to, sector } = useContext(StateContext);
+
+  useEffect(() => {
+    if (!userToken) return;
+    setIsLoading(true);
+    // generalTable(from, to, userToken).then(setData);
+    generalTable(from, to, userToken).then((res) => {
+      setData(res);
+      setIsLoading(false);
+    });
+  }, [from, to, userToken]);
+
+  // Formato de los summaries(filas)
+  const rows = data?.map((x) => (
+    <tr key={x.station.station_code}>
+      <th
+        className={classes.cell}
+        style={{
+          color: x.station.station_code === sector ? "#0d6efd" : undefined,
+        }}
+      >
+        {x.station.title}
+      </th>
+      <TdGroup>
+        <td
+          style={{ backgroundColor: blueScale([0, 50])(x.lt10).css() as any }}
+        >
+          {x.lt10 ?? "-"}%
+        </td>
+        <td
+          style={{ backgroundColor: orangeScale([0, 50])(x.gt33).css() as any }}
+        >
+          {x.gt30 ?? "-"}%
+        </td>
+        <td style={{ backgroundColor: redScale([0, 50])(x.gt33).css() as any }}>
+          {x.gt33 ?? "-"}%
+        </td>
+      </TdGroup>
+
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: greenScale([0, 3000])(x.grados_acumulados).css(),
+        }}
+      >
+        {x.grados_acumulados ?? "-"}
+      </td>
+
+      <td className={classes.cell}>{x.grados_acumulados_promedio ?? "-"}</td>
+
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: yellowScale([0, 50])(x.amplitud_termica).css(),
+        }}
+      >
+        {x.amplitud_termica ?? "-"}
+      </td>
+      <td
+        className={classes.cell}
+        style={{
+          backgroundColor: grayScale([0, 300])(x.precip_acumulada).css(),
+        }}
+      >
+        {x.precip_acumulada ?? "-"}
+      </td>
+      <td className={classes.cell}>
+        {x.data_percentage ? `${x.data_percentage}%` : "-"}
+      </td>
+    </tr>
+  ));
+
+  // Tabla
+  return (
+    <>
+      <table className={`${classes.table}`}>
+        <Header averageAccumulated mainColumnHeader="Finca" />
+        {/* Mostrar las filas si hay data */}
+        <tbody>{data && !isLoading && rows}</tbody>
+      </table>
+      {/* Si no hay data, mostrar una spinner abajo de la tabla */}
+      {isLoading && (
+        <div className="d-flex py-3 justify-content-center">
+          <div className="spinner-border" role="status">
+            <span className="visually-hidden">Loading...</span>
+          </div>
+        </div>
+      )}
+      {data && data.length === 0 && (
+        <div className="d-flex py-3 justify-content-center">
+          <div role="status">
+            <p>Sin datos</p>
+          </div>
+        </div>
+      )}
+    </>
+  );
+};
+export default GeneralPerSector;

+ 6 - 0
frontend/src/components/data/HeatMap/index.tsx

@@ -0,0 +1,6 @@
+import React, { FC } from "react";
+
+const HeatMap: FC = () => {
+  return null;
+};
+export default HeatMap;

+ 88 - 0
frontend/src/components/data/Precipitation/index.tsx

@@ -0,0 +1,88 @@
+import React, { FC, useState, useEffect, useContext } from "react";
+import { monthlyPrecip } from "../../../api";
+import { UserStateContext } from "../../../context/auth/AuthProvider";
+import { StateContext } from "../../../context/dashboard/Provider";
+import { Precipitations } from "../../../types";
+import * as classes from "../tables.module.css";
+import { blueScale, campString } from "../../../utils";
+
+interface PrecipitationProps {}
+
+const colorScale = blueScale([0, 300]);
+
+const ResultTd = ({ value }: any) => (
+  <td
+    className={classes.cell}
+    style={{ backgroundColor: (colorScale(value) as any).css() as any }}
+  >
+    {value ?? "-"}
+  </td>
+);
+
+const Precipitation: FC<PrecipitationProps> = () => {
+  const [data, setData] = useState<Precipitations[] | null>(null);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const { userToken } = useContext(UserStateContext);
+  const { from, to, sector } = useContext(StateContext);
+
+  useEffect(() => {
+    if (!userToken || !sector) return;
+    setIsLoading(true);
+    monthlyPrecip(from, to, sector, userToken).then((res) => {
+      setData(res);
+      setIsLoading(false);
+    });
+  }, [from, to, userToken, sector]);
+
+  const rows = data?.map((x) => (
+    <tr key={x.initial_date}>
+      <th className={classes.cell}>
+        {campString(x.initial_date, x.final_date)}
+      </th>
+      <ResultTd value={x.months[10]} />
+      <ResultTd value={x.months[11]} />
+      <ResultTd value={x.months[12]} />
+      <ResultTd value={x.months[1]} />
+      <ResultTd value={x.months[2]} />
+      <ResultTd value={x.months[3]} />
+      <td className={classes.cell}>
+        {x.data_percentage ? `${x.data_percentage}%` : "-"}
+      </td>
+    </tr>
+  ));
+
+  return (
+    <section>
+      <h5>Precipitaciones mensuales - {data ? data[0].station.title : null}</h5>
+
+      <table className={`${classes.table} ${classes.widthSeven}`}>
+        <thead>
+          <th className={`${classes.cell} ${classes.dateCell}`}>Temporada</th>
+          <td className={classes.cell}>Octubre</td>
+          <td className={classes.cell}>Noviembre</td>
+          <td className={classes.cell}>Diciembre</td>
+          <td className={classes.cell}>Enero</td>
+          <td className={classes.cell}>Febrero</td>
+          <td className={classes.cell}>Marzo</td>
+          <td className={classes.cell}>Porcentaje de datos</td>
+        </thead>
+        <tbody>{data && !isLoading && rows}</tbody>
+      </table>
+      {isLoading && (
+        <div className="d-flex py-3 justify-content-center">
+          <div className="spinner-border" role="status">
+            <span className="visually-hidden">Loading...</span>
+          </div>
+        </div>
+      )}
+      {data && data.length === 0 && (
+        <div className="d-flex py-3 justify-content-center">
+          <div role="status">
+            <p>Sin datos</p>
+          </div>
+        </div>
+      )}
+    </section>
+  );
+};
+export default Precipitation;

+ 118 - 0
frontend/src/components/data/shared.tsx

@@ -0,0 +1,118 @@
+import React, { FC } from "react";
+import * as classes from "./tables.module.css";
+import {
+  redScale,
+  blueScale,
+  orangeScale,
+  greenScale,
+  yellowScale,
+  grayScale,
+} from "../../utils";
+
+interface tableHeaderProps {
+  //Change this name
+  daysToMatchCurrentTemperature?: boolean;
+  averageAccumulated?: boolean;
+  mainColumnHeader?: string;
+}
+
+export const TableHeader: FC<tableHeaderProps> = ({
+  daysToMatchCurrentTemperature = false,
+  averageAccumulated = false,
+  mainColumnHeader = "Temporada",
+}) => {
+  return (
+    <thead style={{ minHeight: "90px" }}>
+      <th className={`${classes.cell} ${classes.dateCell}`}>
+        {mainColumnHeader}
+      </th>
+      <td style={{ padding: "0" }} className={classes.cell}>
+        <div className="d-block h-100">
+          <div className="h-75">
+            <div className="row">
+              <td colSpan={100}>% Horas segun temperatura</td>
+            </div>
+          </div>
+          <div className="h-25">
+            <div className="row mx-0 h-100">
+              <div
+                className="col-4"
+                style={{ backgroundColor: blueScale([])(1).css() }}
+              >
+                &lt;10&deg;
+              </div>
+              <div
+                className="col-4"
+                style={{ backgroundColor: orangeScale([])(1).css() }}
+              >
+                &gt;30&deg;
+              </div>
+              <div
+                className="col-4"
+                style={{
+                  backgroundColor: redScale([])(1).css(),
+                  color: "white",
+                }}
+              >
+                &gt;33&deg;
+              </div>
+            </div>
+          </div>
+        </div>
+      </td>
+
+      <td
+        className={classes.cell}
+        style={{ backgroundColor: greenScale([])(1).css() }}
+      >
+        Grados días acumulados
+      </td>
+
+      {averageAccumulated && (
+        <td className={classes.cell}>Grados dias promedio</td>
+      )}
+
+      {daysToMatchCurrentTemperature && (
+        <td className={classes.cell}>Días para igualar temporada actual</td>
+      )}
+
+      <td
+        className={classes.cell}
+        style={{ backgroundColor: yellowScale([])(1).css() }}
+      >
+        Amplitud térmica
+      </td>
+
+      <td
+        className={classes.cell}
+        style={{ backgroundColor: grayScale([])(1).css() }}
+      >
+        Precipitaciones acumuladas [mm]
+      </td>
+      <td className={classes.cell}>Porcentaje de datos</td>
+    </thead>
+  );
+};
+
+export const TdGroup: FC = ({ children }) => {
+  return (
+    <td className={classes.tdGroup}>
+      <table style={{ position: "relative" }}>
+        <tr
+          style={{
+            display: "grid",
+            gridTemplateRows: "100%",
+            gridTemplateColumns: "repeat(3, 1fr)",
+            position: "absolute",
+            top: 0,
+            left: 0,
+            bottom: 0,
+            right: 0,
+          }}
+        >
+          {children}
+        </tr>
+      </table>
+    </td>
+  );
+};

+ 54 - 0
frontend/src/components/data/tables.module.css

@@ -0,0 +1,54 @@
+.table {
+  width: 100%;
+  display: grid;
+}
+
+.table tr,
+.table thead {
+  display: grid;
+  grid-template-columns: 180px 180px repeat(auto-fit, minmax(50px, 1fr));
+}
+
+.table .threeSlot {
+  display: grid;
+  grid-template-columns: repeat(3, 30%);
+}
+
+.table .oneSlot {
+  display: block;
+}
+
+.table * {
+  text-align: center;
+}
+
+.tdGroup {
+  padding: 0;
+  border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.table .tdGroup,
+.tdGroup td {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.table .tdGroup,
+.tdGroup table {
+  height: 100%;
+  width: 100%;
+}
+
+.cell {
+  padding: 5px 0;
+  border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.nowrap {
+  white-space: nowrap;
+}
+
+/* .dateCell { */
+/*   width: 17%; */
+/* } */

+ 42 - 0
frontend/src/components/hoc/ErrorBoundary.tsx

@@ -0,0 +1,42 @@
+import React, { ClassicComponent, ErrorInfo } from "react";
+import Modal from "../portals/modal";
+
+export class ErrorBoundary extends React.Component<any, any> {
+  constructor(props: any) {
+    super(props);
+  }
+
+  state = {
+    currentError: null,
+    errorInfo: null,
+  };
+
+  componentDidCatch(error: Error, info: ErrorInfo) {
+    this.setState({
+      currentError: error,
+      errorInfo: info,
+    });
+  }
+
+  render() {
+    return (
+      <>
+        {this.props.children}
+        {this.state.currentError && (
+          <Modal
+            key="Error"
+            title={"Error"}
+            staticBackdrop
+            accept={() =>
+              this.setState({ currentError: null, errorInfo: null })
+            }
+          >
+            <p style={{ color: "red" }}>{this.state.errorInfo}</p>
+          </Modal>
+        )}
+      </>
+    );
+  }
+}
+
+export default ErrorBoundary;

+ 7 - 0
frontend/src/components/layout/footer.tsx

@@ -0,0 +1,7 @@
+import React, { FC } from "react";
+
+const Header: FC<{ title: string }> = ({ children }) => {
+  return <main>{children}</main>;
+};
+
+export default Header;

+ 2 - 0
frontend/src/components/layout/header.module.css

@@ -0,0 +1,2 @@
+.header {
+}

+ 17 - 0
frontend/src/components/layout/header.tsx

@@ -0,0 +1,17 @@
+import React from "react";
+import * as classes from "./header.module.css";
+
+const Header = ({ children, toggle }: any) => {
+  return (
+    <header className="bg-dark d-flex justify-content-between align-items-center">
+      <div>
+        <i className="bi bi-house text-light p-3"></i>
+      </div>
+      <button className="btn btn-link d-sm-none" onClick={toggle}>
+        <i className="bi bi-list bi-xl text-light color-white"></i>
+      </button>
+    </header>
+  );
+};
+
+export default Header;

+ 27 - 0
frontend/src/components/layout/index.tsx

@@ -0,0 +1,27 @@
+import React, { useState } from "react";
+import Sidebar from "./sidebar";
+import "bootstrap-icons/font/bootstrap-icons.css";
+
+const Layout = ({ children }: any) => {
+  const [isOpen, setIsOpen] = useState<Boolean>(false);
+  const toggle = () => {
+    setIsOpen((s) => !s);
+  };
+
+  const sbClass = `${isOpen ? "" : "d-none d-sm-block"}`;
+
+  return (
+    <>
+      <div className="container-fluid mx-auto px-0 row flex-nowrap">
+        <section
+          className={`sidebar bg-light col-auto vh-100 sticky-top col-1 ${sbClass}`}
+        >
+          <Sidebar />
+        </section>
+        <main className="col p-3">{children}</main>
+      </div>
+    </>
+  );
+};
+
+export default Layout;

+ 32 - 0
frontend/src/components/layout/sidebar.tsx

@@ -0,0 +1,32 @@
+import React, { useContext } from "react";
+import { Link } from "react-router-dom";
+import { logout } from "../../context/auth/actions";
+import { UserDispatchContext } from "../../context/auth/AuthProvider";
+
+const NavLink = ({ children, ...props }: any) => (
+  <Link className="nav-link" {...props}>
+    {children}
+  </Link>
+);
+
+const Sidebar = () => {
+  const dispatch = useContext(UserDispatchContext);
+
+  return (
+    <aside className="py-5 d-flex flex-column h-100">
+      <ul className="nav nav-pills text-center flex-column mb-auto">
+        <li>
+          <NavLink to="/">Inicio</NavLink>
+        </li>
+      </ul>
+      <button
+        className="nav-link btn btn-link"
+        onClick={() => dispatch(logout())}
+      >
+        Logout
+      </button>
+    </aside>
+  );
+};
+
+export default Sidebar;

+ 38 - 0
frontend/src/components/pages/Home.tsx

@@ -0,0 +1,38 @@
+import React, { useContext, FC } from "react";
+import GeneralPerSector from "../data/GeneralPerSector";
+import { UserStateContext } from "../../context/auth/AuthProvider";
+import DashboardProvider from "../../context/dashboard/Provider";
+import { Redirect } from "react-router-dom";
+import Layout from "../layout";
+import Cockpit from "../UI/dashboard/cockpit";
+import Detail from "../UI/dashboard/detail";
+
+const Home: FC = () => {
+  const userState = useContext(UserStateContext);
+
+  if (!userState.loggedIn) {
+    return <Redirect to="/login" />;
+  }
+
+  return (
+    <>
+      <Layout>
+        <DashboardProvider>
+          <h4>Tabla comparativa entre fincas</h4>
+          <div className="p-2">
+            <Cockpit />
+            <section className="row mb-5">
+              <div className="col-xl-10">
+                <GeneralPerSector />
+              </div>
+            </section>
+
+            <Detail />
+          </div>
+        </DashboardProvider>
+      </Layout>
+    </>
+  );
+};
+
+export default Home;

+ 29 - 0
frontend/src/components/pages/Login.module.css

@@ -0,0 +1,29 @@
+.loginForm {
+  width: 100%;
+  max-width: 400px;
+  padding: 0 5px;
+}
+
+.poweredBy {
+  display: flex;
+  gap: 10px;
+  position: absolute;
+  right: 40px;
+  bottom: 40px;
+}
+
+.passWrapper {
+  position: relative;
+}
+
+.vis {
+  opacity: 0.5;
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+.vis.active {
+  opacity: 1;
+}

+ 151 - 0
frontend/src/components/pages/Login.tsx

@@ -0,0 +1,151 @@
+import React, {
+  ChangeEvent,
+  useContext,
+  useMemo,
+  FormEventHandler,
+  useState,
+} from "react";
+import {
+  UserDispatchContext,
+  UserStateContext,
+} from "../../context/auth/AuthProvider";
+import { Redirect } from "react-router-dom";
+import * as classes from "./Login.module.css";
+import { failureDismiss, login } from "../../context/auth/actions";
+import Modal from "../portals/modal";
+import bg from "../../images/background.jpg";
+import omx from "../../images/omx-logo.png";
+import vis from "../../images/visibility.png";
+
+const Login = () => {
+  const [formState, setFormState] = useState<any>({});
+  const [passVisible, setPassVisible] = useState(false);
+  const dispatch = useContext(UserDispatchContext);
+  const userState = useContext(UserStateContext);
+
+  // Medio chota la validacion quizas despues usemos una alternativa mejor
+  const isValid = useMemo<Boolean>(() => {
+    let valid = true;
+    valid = valid && Object.keys(formState).length >= 2;
+    for (let key in formState) {
+      valid = valid && formState[key] && formState != "";
+    }
+
+    return valid;
+  }, [formState]);
+
+  const handleFormChange = (e: ChangeEvent) => {
+    const el = e.target as HTMLInputElement;
+    const name = el.getAttribute("name") as string;
+    const value = el.value;
+    setFormState({
+      ...formState,
+      [name]: value,
+    });
+  };
+
+  const handleFormSubmit: FormEventHandler = (e) => {
+    e.preventDefault();
+    dispatch(login(formState.username, formState.password));
+  };
+
+  if (userState.loggedIn) {
+    return <Redirect to="/" />;
+  }
+
+  // Modal
+  let modal = null;
+  if (userState.loggingIn) {
+    modal = (
+      <Modal key="Loading" buttons={false}>
+        <p>Espere un momento</p>
+      </Modal>
+    );
+  } else if (userState.error) {
+    modal = (
+      <Modal
+        key="Error"
+        title={"Error"}
+        staticBackdrop
+        accept={() => dispatch(failureDismiss())}
+      >
+        <p>{userState.error}</p>
+      </Modal>
+    );
+  }
+
+  return (
+    <main
+      style={{
+        background: `url(${bg}) no-repeat`,
+        backgroundSize: "cover",
+        backgroundPosition: "30%",
+      }}
+      className="d-flex justify-content-center align-items-center min-vh-100"
+    >
+      {modal}
+      <div className="w-50">
+        <form
+          onSubmit={handleFormSubmit}
+          className={`text-center d-flex flex-column gap-2 mx-auto  ${classes.loginForm}`}
+        >
+          <h3 className="text-light">Inicio de sesión</h3>
+          <input
+            onChange={handleFormChange}
+            id="username"
+            className="form-control"
+            type="text"
+            name="username"
+            placeholder="Usuario"
+          />
+          <div className={classes.passWrapper}>
+            <input
+              onChange={handleFormChange}
+              id="password"
+              className="form-control"
+              type={passVisible ? "text" : "password"}
+              name="password"
+              placeholder="Contraseña"
+            />
+            <img
+              src={vis}
+              className={`${classes.vis} ${passVisible ? classes.active : ""}`}
+              onClick={() => setPassVisible(!passVisible)}
+              alt="Ver contraseña"
+            />
+          </div>
+          <button
+            className={`btn btn-primary ${!isValid && "disabled"}`}
+            disabled={!isValid}
+            type="submit"
+          >
+            Continuar
+          </button>
+          <hr className="text-light border" />
+          <a
+            href="https://new.omixom.com/accounts/login/?next=/"
+            target="_blank"
+            className="text-start text-light text-decoration-none"
+          >
+            Crear una cuenta
+          </a>
+          <a
+            href="https://new.omixom.com/accounts/login/?next=/"
+            target="_blank"
+            className="text-start text-light text-decoration-none"
+          >
+            Olvide mi contraseña
+          </a>
+        </form>
+      </div>
+      <div className={classes.poweredBy}>
+        <span className="text-light">Powered by</span>
+        <a href="https://www.omixom.com" target="_blank">
+          <img src={omx} alt="Logo Omixom" />
+        </a>
+      </div>
+    </main>
+  );
+};
+
+export default Login;

+ 92 - 0
frontend/src/components/portals/modal/index.tsx

@@ -0,0 +1,92 @@
+import React, { FC, useState, useEffect, MouseEventHandler } from "react";
+import { createPortal } from "react-dom";
+import { Modal as BsModal } from "bootstrap";
+
+// Este componente es un react portal. No se va a montar adentro de el componente donde lo uses.
+// Cuando lo uses, va a montarse siempre en el mismo lugar, pero tenes acceso a todos sus eventos de todas formas.
+const Modal: FC<ModalProps> = ({
+  children,
+  accept,
+  dismiss,
+  acceptText = "Aceptar",
+  dismissText = "Cancelar",
+  title,
+  buttons = true,
+  staticBackdrop = false,
+}) => {
+  // Mounting point
+  const root = document.getElementById("modal-portal");
+  const [bsModal, setBsModal] = useState<BsModal | null>(null);
+
+  useEffect(() => {
+    const modalEl = document.getElementById("page-modal");
+    let modalTemp: null | BsModal = null;
+    if (!modalEl) return;
+    console.log("Effect run", { staticBackdrop });
+    if (staticBackdrop) {
+      console.log({ modalEl });
+      modalEl.setAttribute("data-bs-backdrop", "static");
+    }
+    if (!bsModal) {
+      modalTemp = new BsModal(modalEl);
+      setBsModal(modalTemp);
+    }
+    modalTemp?.show();
+
+    return () => {
+      modalTemp?.hide();
+      modalEl?.removeAttribute("data-bs-backdrop");
+    };
+  }, []);
+
+  const content = (
+    <div className="modal-content">
+      {title ? (
+        <div className="modal-header">
+          <h5 className="modal-title">{title}</h5>
+          <button
+            type="button"
+            className="btn-close"
+            data-bs-dismiss="modal"
+            aria-label="Close"
+          ></button>
+        </div>
+      ) : null}
+      <div className="modal-body">{children}</div>
+      {buttons && (dismiss || accept) && (
+        <div className="modal-footer">
+          {dismiss && (
+            <button
+              type="button"
+              className="btn btn-secondary"
+              data-bs-dismiss="modal"
+              onClick={dismiss}
+            >
+              {dismissText}
+            </button>
+          )}
+          {accept && (
+            <button type="button" onClick={accept} className="btn btn-primary">
+              {acceptText}
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+
+  if (!root) return null;
+  return createPortal(content, root);
+};
+
+interface ModalProps {
+  title?: string;
+  buttons?: boolean;
+  dismiss?: MouseEventHandler<HTMLButtonElement>;
+  accept?: MouseEventHandler<HTMLButtonElement>;
+  dismissText?: string;
+  acceptText?: string;
+  staticBackdrop?: boolean;
+}
+
+export default Modal;

+ 2 - 0
frontend/src/components/portals/modal/modal.module.css

@@ -0,0 +1,2 @@
+.modal {
+}

+ 5 - 0
frontend/src/config/index.ts

@@ -0,0 +1,5 @@
+export const apiURL = process.env.REACT_APP_API_URL || "http://localhost:8000/";
+
+export default {
+  apiURL,
+};

+ 38 - 0
frontend/src/context/auth/AuthProvider.tsx

@@ -0,0 +1,38 @@
+import React, { createContext, useReducer, Dispatch, FC } from "react";
+import reducer, { State, getInitialState } from "./reducer";
+import { Action } from "./actionTypes";
+
+const UserProvider: FC = ({ children }) => {
+  const [state, dispatch] = useReducer(reducer, getInitialState());
+
+  return (
+    <UserStateContext.Provider value={state}>
+      <UserDispatchContext.Provider value={asyncDispatchWrap(dispatch)}>
+        {children}
+      </UserDispatchContext.Provider>
+    </UserStateContext.Provider>
+  );
+};
+
+type AsyncDispatch = (
+  action: Action | ((...args: any) => Promise<any>)
+) => void;
+
+function asyncDispatchWrap(dispatch: Dispatch<Action>) {
+  const asyncDispatch: AsyncDispatch = (action) => {
+    if (action instanceof Function) {
+      action(dispatch);
+      return;
+    }
+    dispatch(action);
+  };
+
+  return asyncDispatch;
+}
+
+export const UserStateContext = createContext<State>({});
+export const UserDispatchContext = createContext<
+  (action: Action | ((...args: any) => Promise<any>)) => void
+>(asyncDispatchWrap((dispatch) => {}));
+
+export default UserProvider;

+ 20 - 0
frontend/src/context/auth/actionTypes.ts

@@ -0,0 +1,20 @@
+// Idealmente estas constantes NO deberían usarse.
+// Deberíamos preferir usar los tipos de abajo.
+export const LOGIN_REQUEST = "LOGIN_REQUEST";
+export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
+export const LOGIN_FAILURE = "LOGIN_FAILURE";
+export const LOGOUT = "LOGOUT";
+
+export type ActionType =
+  | "LOGIN_REQUEST"
+  | "LOGIN_SUCCESS"
+  | "LOGIN_FAILURE_DISMISS"
+  | "LOGIN_FAILURE"
+  | "LOGOUT";
+
+export type Action = {
+  type: ActionType;
+  username?: string | null;
+  userToken?: string | null;
+  error?: string | null;
+};

+ 71 - 0
frontend/src/context/auth/actions.ts

@@ -0,0 +1,71 @@
+import { Dispatch } from "react";
+import { Action } from "./actionTypes";
+import { login as apiLoginRequest } from "../../api";
+import keys from "../storageKeys";
+
+// Accion asincrona: Loguea al usuario
+export const login = (username: string, password: string) => async (
+  dispatch: Dispatch<Action>
+) => {
+  dispatch(loginRequest());
+
+  const errors = {
+    400: "Credenciales inválidas",
+  };
+
+  try {
+    const res = await apiLoginRequest(username, password);
+    if (!res.ok) {
+      const msg = (errors as any)[res.status];
+      dispatch(loginFailure(msg ?? "Request inválida"));
+      const text = await res.text();
+      console.log("Error en el login", text);
+      return;
+    }
+
+    const body = await res.json();
+
+    const userToken = body.access_token;
+    localStorage.setItem(keys.username, username);
+    localStorage.setItem(keys.userToken, userToken);
+
+    dispatch(loginSuccess(username, userToken));
+  } catch (e) {
+    console.error(e);
+    dispatch(loginFailure("Error de conexión"));
+  }
+};
+
+// Accion: estado de carga durante login pendiente
+export const loginRequest = (): Action => ({
+  type: "LOGIN_REQUEST",
+});
+
+// Accion: Estado de login exitoso
+export const loginSuccess = (username: string, userToken: string): Action => ({
+  type: "LOGIN_SUCCESS",
+  username,
+  userToken,
+});
+
+// Accion: Estado de login fallido
+export const loginFailure = (code: string): Action => ({
+  type: "LOGIN_FAILURE",
+  error: code,
+});
+
+// Accion: Quitar estado de error
+export const failureDismiss = (): Action => ({
+  type: "LOGIN_FAILURE_DISMISS",
+});
+
+// Accion: logout
+export const logoutSync = (): Action => ({
+  type: "LOGOUT",
+});
+
+export const logout = () => async (dispatch: Dispatch<Action>) => {
+  localStorage.removeItem(keys.userToken);
+
+  dispatch(logoutSync());
+};

+ 59 - 0
frontend/src/context/auth/reducer.ts

@@ -0,0 +1,59 @@
+import { Reducer } from "react";
+import { Action } from "./actionTypes";
+import keys from "../storageKeys";
+
+export const defaultState = {
+  loggingIn: false,
+  loggedIn: false,
+  userToken: null,
+  username: null,
+};
+
+// Estado inicial de usuario
+export const getInitialState = (): State => {
+  const userToken = localStorage.getItem(keys.userToken);
+  const username = localStorage.getItem(keys.username);
+  return { ...defaultState, username, userToken, loggedIn: !!userToken };
+};
+
+export type State = {
+  loggingIn?: Boolean | null;
+  loggedIn?: Boolean | null;
+  userToken?: string | null;
+  username?: string | null;
+  error?: string | null;
+};
+
+const reducer: Reducer<State, Action> = (state, action) => {
+  switch (action.type) {
+    case "LOGIN_REQUEST":
+      return {
+        ...state,
+        loggingIn: true,
+        loggedIn: false,
+        username: action.username,
+      };
+    case "LOGIN_SUCCESS":
+      return {
+        loggedIn: true,
+        loggingIn: false,
+        username: action.username,
+        userToken: action.userToken,
+      };
+    case "LOGIN_FAILURE_DISMISS":
+      return {
+        ...state,
+        error: undefined,
+      };
+    case "LOGIN_FAILURE":
+      return {
+        error: action.error,
+      };
+    case "LOGOUT":
+      return {};
+    default:
+      return state;
+  }
+};
+
+export default reducer;

+ 48 - 0
frontend/src/context/dashboard/Provider.tsx

@@ -0,0 +1,48 @@
+import React, { createContext, useReducer, Dispatch, FC } from "react";
+import reducer, { State, getInitialState } from "./reducer";
+import { Action } from "./actionTypes";
+
+const Provider: FC = ({ children }) => {
+  const [state, dispatch] = useReducer(reducer, getInitialState());
+
+  return (
+    <StateContext.Provider value={state}>
+      <DispatchContext.Provider value={asyncDispatchWrap(dispatch)}>
+        {children}
+      </DispatchContext.Provider>
+    </StateContext.Provider>
+  );
+};
+
+type AsyncDispatch = (
+  action: Action | ((...args: any) => Promise<any>)
+) => void;
+
+function asyncDispatchWrap(dispatch: Dispatch<Action>) {
+  const asyncDispatch: AsyncDispatch = (action) => {
+    if (action instanceof Function) {
+      action(dispatch);
+      return;
+    }
+    dispatch(action);
+  };
+
+  return asyncDispatch;
+}
+
+// Este no es el estado default!
+// createContext necesita un valor default pero los providers siempre van a tener una prop 'value'
+export const StateContext = createContext<State>({
+  sector: null,
+  year: "",
+  from: "",
+  to: "",
+  maxDate: "",
+  minDate: "",
+  years: [],
+});
+export const DispatchContext = createContext<
+  (action: Action | ((...args: any) => Promise<any>)) => void
+>(asyncDispatchWrap((dispatch) => {}));
+
+export default Provider;

+ 19 - 0
frontend/src/context/dashboard/actionTypes.ts

@@ -0,0 +1,19 @@
+export type ActionType =
+  | "SET_FROM_CONTROL"
+  | "SET_TO_CONTROL"
+  | "SET_YEAR_CONTROL"
+  | "SET_MIN_DATE"
+  | "SET_MAX_DATE"
+  | "SET_MIN_MAX_DATE"
+  | "SET_SECTOR";
+
+export type Action = {
+  type: ActionType;
+  sector?: string | null;
+  to?: string | null;
+  from?: string | null;
+  year?: string | null;
+  date?: string;
+  minDate?: string;
+  maxDate?: string;
+};

+ 0 - 0
frontend/src/context/dashboard/actions.ts


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels