diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 68a1f2da..037373d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.10.0 hooks: - id: black language_version: python3.9 diff --git a/Dockerfile b/Dockerfile index f6ed7e93..3a3d3c13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ # # ---------------------------------------------------------------------------- # Build node / Frontend assets -FROM node:alpine3.15 as build_node +FROM node:lts-alpine as build_node MAINTAINER Samantha Finnigan , ARC Durham University # Install Python (required for node-gyp) @@ -50,7 +50,7 @@ RUN npm run build # ---------------------------------------------------------------------------- # Create conda environment -FROM continuumio/miniconda3:4.12.0 as build_python +FROM continuumio/miniconda3 as build_python # https://pythonspeed.com/articles/conda-docker-image-size/ # Create the environment: @@ -93,7 +93,7 @@ RUN source /venv/bin/activate && \ # ---------------------------------------------------------------------------- # Create a python docker container base for gunicorn and celery -FROM debian:bullseye-slim as manyfews +FROM debian:bookworm-slim as manyfews MAINTAINER Samantha Finnigan , ARC Durham University WORKDIR /app diff --git a/config/manyFEWS.base.yml b/config/manyFEWS.base.yml index ee37c37d..6b2648fb 100644 --- a/config/manyFEWS.base.yml +++ b/config/manyFEWS.base.yml @@ -3,15 +3,14 @@ channels: - conda-forge - defaults dependencies: - - numpy=1.21.2 - - numba=0.57.1 - - pandas=1.4.4 - - xlrd=1.2.0 + - numpy>=1.24,<2 + - numba>=0.57.1 + - pandas>=2.0 - pip - psycopg2 - - django + - django>=4.2,<5.1 - django-leaflet - - celery=5.2.7 + - celery>=5.4 - django-celery-beat - django-celery-results - django-crispy-forms @@ -21,7 +20,9 @@ dependencies: - pygrib - gdal - gunicorn - - retrying=1.3.3 + - tenacity + - requests + - openpyxl - pip: - django-geojson>=4.0.0 - django-phonenumber-field[phonenumberslite] diff --git a/manyfews/calculations/gefs.py b/manyfews/calculations/gefs.py index 8cc39dbb..ff2d60c9 100644 --- a/manyfews/calculations/gefs.py +++ b/manyfews/calculations/gefs.py @@ -1,15 +1,17 @@ +import os +import tempfile from urllib.request import urlretrieve import pygrib import numpy as np from datetime import datetime, timedelta, timezone from .models import NoaaForecast from django.contrib.gis.geos import Point -from retrying import retry +from tenacity import retry, stop_after_attempt, wait_fixed from django.conf import settings from tqdm import trange -# if report error, retrying 72 times (6 hours), sleep 300 seconds (5 minutes) between attempts -@retry(stop_max_attempt_number=72, wait_fixed=300) +# Retry up to 72 times (6 hours), sleeping 300 seconds (5 minutes) between attempts +@retry(stop=stop_after_attempt(72), wait=wait_fixed(300)) def GEFSdownloader(fileDate, forecastHour, latValue, lonValue): """ This script is developed to download files, read files, and export necessary data for generating river flows. @@ -48,77 +50,111 @@ def GEFSdownloader(fileDate, forecastHour, latValue, lonValue): fileName = fileNameBase + (str(forecastHour)).zfill(3) fullUrl = rootUrl + "gefs." + fileDate + subUrl + fileName - gefsFile = urlretrieve(fullUrl) - gefsData = pygrib.open(gefsFile[0]) - - # extract necessary data sets: - for grb in gefsData: - - # metre relative humidity, unit:% (instant). - if grb.parameterName == "Relative humidity" and grb.level == 2: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - RHvalue = grb.values[index] - - # Maximum temperature: unit:K (max). - elif grb.parameterName == "Maximum temperature" and grb.level == 2: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - maxTempValue = grb.values[index] - - # Minimum temperature: unit:K (min). - elif grb.parameterName == "Minimum temperature" and grb.level == 2: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - minTempValue = grb.values[index] - - # metre U wind component: unit:m/s (instant). - elif grb.parameterName == "u-component of wind" and grb.level == 10: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - uWindValue = grb.values[index] - - # metre V wind component: unit:m/s (instant). - elif grb.parameterName == "v-component of wind" and grb.level == 10: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - vWindValue = grb.values[index] - - # Total Precipitation: unit:kg/m2 (accum). - elif grb.parameterName == "Total precipitation" and grb.level == 0: - lats, lons = grb.latlons() - index = cellIndexFinder( - latitudeInfo=lats, - longitudeInfo=lons, - latValue=latValue, - lonValue=lonValue, - ) - totalPrecipValue = grb.values[index] + # Initialise output variables; will raise a clear error if any remain None. + RHvalue = None + maxTempValue = None + minTempValue = None + uWindValue = None + vWindValue = None + totalPrecipValue = None + + tmp_fd, tmp_path = tempfile.mkstemp() + os.close(tmp_fd) + gefsData = None + try: + urlretrieve(fullUrl, tmp_path) + gefsData = pygrib.open(tmp_path) + + # extract necessary data sets: + for grb in gefsData: + + # metre relative humidity, unit:% (instant). + if grb.parameterName == "Relative humidity" and grb.level == 2: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + RHvalue = grb.values[index] + + # Maximum temperature: unit:K (max). + elif grb.parameterName == "Maximum temperature" and grb.level == 2: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + maxTempValue = grb.values[index] + + # Minimum temperature: unit:K (min). + elif grb.parameterName == "Minimum temperature" and grb.level == 2: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + minTempValue = grb.values[index] + + # metre U wind component: unit:m/s (instant). + elif grb.parameterName == "u-component of wind" and grb.level == 10: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + uWindValue = grb.values[index] + + # metre V wind component: unit:m/s (instant). + elif grb.parameterName == "v-component of wind" and grb.level == 10: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + vWindValue = grb.values[index] + + # Total Precipitation: unit:kg/m2 (accum). + elif grb.parameterName == "Total precipitation" and grb.level == 0: + lats, lons = grb.latlons() + index = cellIndexFinder( + latitudeInfo=lats, + longitudeInfo=lons, + latValue=latValue, + lonValue=lonValue, + ) + totalPrecipValue = grb.values[index] + + finally: + if gefsData is not None: + gefsData.close() + os.unlink(tmp_path) + + missing = [ + name + for name, val in ( + ("Relative humidity", RHvalue), + ("Maximum temperature", maxTempValue), + ("Minimum temperature", minTempValue), + ("U wind component", uWindValue), + ("V wind component", vWindValue), + ("Total precipitation", totalPrecipValue), + ) + if val is None + ] + if missing: + raise ValueError( + f"GEFS GRIB file for forecastHour={forecastHour} is missing required fields: {missing}" + ) return RHvalue, maxTempValue, minTempValue, uWindValue, vWindValue, totalPrecipValue @@ -163,9 +199,9 @@ def prepareGEFS(): loopRange = int(forecastDays / dt) deltaHour = int(24 * dt) - downloadDate = datetime.utcnow() # download today's GEFS data. + downloadDate = datetime.now(tz=timezone.utc) # download today's GEFS data. fileDate = downloadDate.strftime("%Y%m%d") - date = datetime.astimezone(downloadDate, tz=timezone(timedelta(hours=0))) + date = downloadDate.astimezone(tz=timezone(timedelta(hours=0))) # get the lat & lon value of studying cell latValue = settings.LAT_VALUE diff --git a/manyfews/calculations/open_meteo.py b/manyfews/calculations/open_meteo.py new file mode 100644 index 00000000..16a5309c --- /dev/null +++ b/manyfews/calculations/open_meteo.py @@ -0,0 +1,300 @@ +""" +open_meteo.py – Fetch ensemble weather forecasts from the Open-Meteo API +(https://ensemble-api.open-meteo.com/v1/ensemble) and store them in the +NoaaForecast table so the rest of the pipeline can consume them unchanged. + +The Open-Meteo ensemble API is free, requires no API key, and provides +multiple ensemble members for several global NWP models (GFS, ICON, ECMWF …). + +Variables requested +------------------- +precipitation mm (hourly accumulation) +temperature_2m_max °C (daily max; only available at daily resolution in the + ensemble endpoint, so we request hourly + temperature_2m and derive min/max per day) +temperature_2m_min °C +windspeed_10m km/h +winddirection_10m ° (meteorological convention: 0 = wind from North) +relativehumidity_2m % + +Wind components +--------------- +The NoaaForecast model stores ``wind_u`` and ``wind_v`` (zonal / meridional +m s⁻¹). We decompose wind speed + direction into these components: + + U = -speed * sin(direction_rad) + V = -speed * cos(direction_rad) + +(negative sign because meteorological convention reports *from* direction) +""" + +import math +import logging +from collections import defaultdict +from datetime import datetime, timezone + +import requests +from django.conf import settings +from django.contrib.gis.geos import Point +from tenacity import retry, stop_after_attempt, wait_fixed + +from .models import NoaaForecast + +logger = logging.getLogger(__name__) + +# Base URL for the Open-Meteo ensemble forecast API +_BASE_URL = "https://ensemble-api.open-meteo.com/v1/ensemble" + +# Hourly variables to request from the API +_HOURLY_VARIABLES = [ + "precipitation", + "temperature_2m", + "windspeed_10m", + "winddirection_10m", + "relativehumidity_2m", +] + + +def _kmh_to_ms(speed_kmh: float) -> float: + """Convert wind speed from km/h to m/s.""" + return speed_kmh / 3.6 + + +def _wind_components(speed_ms: float, direction_deg: float) -> tuple[float, float]: + """ + Convert wind speed (m/s) and meteorological direction (degrees, *from*) + to zonal (U) and meridional (V) components in m/s. + + :param speed_ms: wind speed in m/s + :param direction_deg: wind direction in degrees (meteorological, 0 = from N) + :return: (wind_u, wind_v) tuple + """ + direction_rad = math.radians(direction_deg) + wind_u = -speed_ms * math.sin(direction_rad) + wind_v = -speed_ms * math.cos(direction_rad) + return wind_u, wind_v + + +@retry(stop=stop_after_attempt(5), wait=wait_fixed(60)) +def _fetch_ensemble_data(lat: float, lon: float, model: str) -> dict: + """ + Fetch raw ensemble forecast JSON from Open-Meteo. + + Retried up to 5 times with a 60-second wait between attempts to handle + transient network errors. + + :param lat: latitude of the forecast location + :param lon: longitude of the forecast location + :param model: Open-Meteo ensemble model identifier (e.g. ``"gfs_seamless"``) + :return: parsed JSON response dict + :raises requests.HTTPError: if the API returns a non-2xx status + """ + params = { + "latitude": lat, + "longitude": lon, + "hourly": ",".join(_HOURLY_VARIABLES), + "models": model, + "timeformat": "unixtime", + "timezone": "UTC", + } + response = requests.get(_BASE_URL, params=params, timeout=30) + response.raise_for_status() + return response.json() + + +def _parse_ensemble_members(data: dict) -> dict[str, list]: + """ + Split the flat hourly response into a dict keyed by member suffix. + + The Open-Meteo ensemble endpoint returns variables with names like + ``precipitation_member01``, ``temperature_2m_member01`` … plus a + ``precipitation`` key for the control / deterministic run. We group + all timesteps for each member into a list of dicts. + + :param data: raw JSON response from the API + :return: dict mapping member label → list of hourly data dicts + """ + hourly = data.get("hourly", {}) + times = hourly.get("time", []) + + if not times: + raise ValueError("Open-Meteo response contained no hourly time steps") + + # Identify all member suffixes present in the response + # Keys look like: "precipitation", "precipitation_member01", … + member_suffixes: list[str] = [] + for key in hourly: + if key.startswith("precipitation"): + suffix = key[len("precipitation"):] # "" for control, "_member01" etc. + member_suffixes.append(suffix) + + if not member_suffixes: + raise ValueError( + "Open-Meteo response contained no precipitation data columns" + ) + + members: dict[str, list] = {} + + def _get_value(key: str, idx: int): + """Safely retrieve the i-th element from an hourly list, returning None if absent.""" + lst = hourly.get(key, []) + return lst[idx] if idx < len(lst) else None + + for suffix in member_suffixes: + label = suffix.lstrip("_") or "control" + rows = [] + for i, ts in enumerate(times): + rows.append( + { + "time": ts, + "precipitation": _get_value(f"precipitation{suffix}", i), + "temperature_2m": _get_value(f"temperature_2m{suffix}", i), + "windspeed_10m": _get_value(f"windspeed_10m{suffix}", i), + "winddirection_10m": _get_value(f"winddirection_10m{suffix}", i), + "relativehumidity_2m": _get_value(f"relativehumidity_2m{suffix}", i), + } + ) + members[label] = rows + + return members + + +def _rows_to_noaa_forecasts( + members: dict[str, list], lat: float, lon: float +) -> list[NoaaForecast]: + """ + Convert parsed ensemble member rows into unsaved ``NoaaForecast`` instances. + + Temperature from Open-Meteo is in °C; the existing GEFS pipeline stores + temperatures in K (converted from GRIB). We keep the same units here: + values are stored in K to remain consistent with the existing schema and + downstream model code. + + Wind speed arrives in km/h and is converted to m/s before decomposition. + + :param members: dict mapping member label → list of hourly row dicts + :param lat: latitude + :param lon: longitude + :return: list of ``NoaaForecast`` objects (not yet saved to the DB) + """ + KELVIN_OFFSET = 273.15 + location = Point(lon, lat) + forecasts: list[NoaaForecast] = [] + + for member_label, rows in members.items(): + # We need per-day min/max temperature from hourly data. + # Group rows by calendar date first. + daily_temps: dict[str, list[float]] = defaultdict(list) + for row in rows: + if row["temperature_2m"] is not None: + day_key = datetime.fromtimestamp( + row["time"], tz=timezone.utc + ).strftime("%Y-%m-%d") + daily_temps[day_key].append(row["temperature_2m"]) + + daily_min: dict[str, float] = { + d: min(temps) for d, temps in daily_temps.items() + } + daily_max: dict[str, float] = { + d: max(temps) for d, temps in daily_temps.items() + } + + for row in rows: + # Skip rows with missing core values + if any( + row[v] is None + for v in ( + "precipitation", + "windspeed_10m", + "winddirection_10m", + "relativehumidity_2m", + ) + ): + logger.warning( + "Skipping row with missing values for member %s at time %s", + member_label, + row["time"], + ) + continue + + forecast_dt = datetime.fromtimestamp(row["time"], tz=timezone.utc) + day_key = forecast_dt.strftime("%Y-%m-%d") + + speed_ms = _kmh_to_ms(row["windspeed_10m"]) + wind_u, wind_v = _wind_components(speed_ms, row["winddirection_10m"]) + + # Use daily min/max from hourly temps; fall back to current hour temp + temp_c = row["temperature_2m"] + min_temp_k = (daily_min.get(day_key, temp_c) + KELVIN_OFFSET) if temp_c is not None else None + max_temp_k = (daily_max.get(day_key, temp_c) + KELVIN_OFFSET) if temp_c is not None else None + + if min_temp_k is None or max_temp_k is None: + logger.warning( + "Skipping row with missing temperature for member %s at time %s", + member_label, + row["time"], + ) + continue + + forecasts.append( + NoaaForecast( + location=location, + date=forecast_dt, + precipitation=row["precipitation"], + min_temperature=min_temp_k, + max_temperature=max_temp_k, + wind_u=wind_u, + wind_v=wind_v, + relative_humidity=row["relativehumidity_2m"], + ) + ) + + return forecasts + + +def prepareOpenMeteo() -> None: + """ + Fetch ensemble weather forecasts from Open-Meteo and save them to the + ``NoaaForecast`` table. + + Configuration is read from Django settings: + + ``LAT_VALUE`` + Latitude of the forecast point (degrees, WGS-84). + ``LON_VALUE`` + Longitude of the forecast point (degrees, WGS-84). + ``OPEN_METEO_MODEL`` + Open-Meteo ensemble model to use (default: ``"gfs_seamless"``). + ``OPEN_METEO_ENSEMBLE_MEMBERS`` + Maximum number of ensemble members to keep (0 = keep all). + + This function mirrors ``prepareGEFS()`` and is a drop-in replacement for + the daily weather-data step in ``dailyModelUpdate()``. + """ + lat = settings.LAT_VALUE + lon = settings.LON_VALUE + model = getattr(settings, "OPEN_METEO_MODEL", "gfs_seamless") + max_members = getattr(settings, "OPEN_METEO_ENSEMBLE_MEMBERS", 0) + + logger.info( + "Fetching Open-Meteo ensemble forecast: model=%s lat=%s lon=%s", + model, + lat, + lon, + ) + + raw = _fetch_ensemble_data(lat=lat, lon=lon, model=model) + members = _parse_ensemble_members(raw) + + if max_members and max_members > 0: + # Keep only the requested number of members (plus control if present) + selected = dict(list(members.items())[:max_members]) + members = selected + + logger.info("Parsed %d ensemble members from Open-Meteo response", len(members)) + + forecasts = _rows_to_noaa_forecasts(members=members, lat=lat, lon=lon) + + logger.info("Saving %d NoaaForecast records to database", len(forecasts)) + NoaaForecast.objects.bulk_create(forecasts, batch_size=500) + logger.info("Open-Meteo ensemble forecast data saved successfully") diff --git a/manyfews/calculations/tasks.py b/manyfews/calculations/tasks.py index 35d29885..f9bc2a09 100644 --- a/manyfews/calculations/tasks.py +++ b/manyfews/calculations/tasks.py @@ -1,6 +1,6 @@ import csv -from celery import Celery, shared_task +from celery import shared_task from django.conf import settings from django.contrib.gis.geos import Point, Polygon @@ -16,6 +16,7 @@ from .bulk_create_manager import BulkCreateManager from .flood_risk import run_all_flood_models, calculate_risk_percentages from .gefs import prepareGEFS +from .open_meteo import prepareOpenMeteo from .generate_river_flows import ( prepareWeatherForecastData, runningGenerateRiverFlows, @@ -41,8 +42,6 @@ logger = get_task_logger(__name__) -app = Celery() - @shared_task(name="calculations.hello_celery") def hello_celery(): @@ -221,9 +220,13 @@ def dailyModelUpdate(): gefsData = NoaaForecast.objects.filter(date__range=(today[0], today[1])) if len(gefsData) == 0: - # Check whether GEFS data has been downloaded - logger.info("Preparing GEFS data") - prepareGEFS() + weather_source = getattr(settings, "WEATHER_SOURCE", "open_meteo") + if weather_source == "gefs": + logger.info("Preparing GEFS weather forecast data") + prepareGEFS() + else: + logger.info("Preparing Open-Meteo ensemble forecast data") + prepareOpenMeteo() weatherForecastData = prepareWeatherForecastData( predictionDate=today[0], location=location, dataSource="gefs" @@ -275,7 +278,7 @@ def send_alerts(): def load_params_from_csv(self, filename: str, model_version_id: str): logger.info(f"Loading parameters from {filename}") - total_rows = sum(1 for _ in open(filename)) + total_rows = sum(1 for _ in open(filename, encoding="utf-8-sig")) logger.info( f"CSV file contains {total_rows} rows. Loading in chunks of {settings.DATABASE_CHUNK_SIZE}..." ) diff --git a/manyfews/calculations/tests.py b/manyfews/calculations/tests.py index 2bcb3eb8..f7f254f9 100644 --- a/manyfews/calculations/tests.py +++ b/manyfews/calculations/tests.py @@ -6,7 +6,7 @@ from django.contrib.gis.geos import MultiPolygon, Point, Polygon from django.test import TestCase import numpy as np -import xlrd +import openpyxl from unittest import mock from webapp.models import UserAlert, UserPhoneNumber, AlertType @@ -44,17 +44,15 @@ def excel_to_matrix(path, sheet_num): """ - table = xlrd.open_workbook(path).sheets()[sheet_num] - row = table.nrows - col = table.ncols - datamatrix = np.zeros((row, col)) # ignore the first title row. - for x in range(1, row): - # row = np.matrix(table.row_values(x)) - row = np.array(table.row_values(x)) - datamatrix[x, :] = row - datamatrix = np.delete( - datamatrix, 0, axis=0 - ) # Delete the first blank line.(Its elements are all zero) + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + sheet = wb.worksheets[sheet_num] + rows = list(sheet.iter_rows(values_only=True)) + row_count = len(rows) - 1 # exclude header row + col_count = len(rows[0]) if rows else 0 + datamatrix = np.zeros((row_count, col_count)) + for x in range(row_count): + datamatrix[x, :] = [float(v) if v is not None else 0.0 for v in rows[x + 1]] + wb.close() return datamatrix @@ -62,7 +60,7 @@ def prepare_test_data(): """ This function is used to import test GEFS and initial condition data into database. - 1. For GEFS: It will read data from GEFS xlrd file (GEFSdata) in Data directory. + 1. For GEFS: It will read data from GEFS Excel file (GEFSdata) in Data directory. 2. For initial condition: It will read data from csv file ( 'RainfallRunoffModelInitialConditions.csv') in Data directory. diff --git a/manyfews/manyfews/settings.py b/manyfews/manyfews/settings.py index 2e810c5c..f69d1ed0 100644 --- a/manyfews/manyfews/settings.py +++ b/manyfews/manyfews/settings.py @@ -24,7 +24,7 @@ # Database details DB_NAME = env.str("DB_NAME", "manyfews") DB_USER = env.str("DB_USER", "manyfews") -DB_PASSWORD = env.str("DB_PASSWORD", "manyfews") +DB_PASSWORD = env.str("DB_PASSWORD") DB_HOST = env.str("DB_HOST", "localhost") DB_PORT = env.int("DB_PORT", 5432) @@ -87,6 +87,15 @@ LAT_VALUE = env.float("LAT_VALUE", -7.05) LON_VALUE = env.float("LON_VALUE", 175) +# Weather source selection: "open_meteo" (default) or "gefs" +WEATHER_SOURCE = env.str("WEATHER_SOURCE", "open_meteo") + +# Open-Meteo ensemble forecast settings +# See https://open-meteo.com/en/docs/ensemble-api for available model identifiers +OPEN_METEO_MODEL = env.str("OPEN_METEO_MODEL", "gfs_seamless") +# Maximum number of ensemble members to store (0 = keep all available members) +OPEN_METEO_ENSEMBLE_MEMBERS = env.int("OPEN_METEO_ENSEMBLE_MEMBERS", 0) + # Thresholds for number of m^2 cells that count towards flood risk # CHANNEL_CELL_COUNT is number of cells in the river channel CHANNEL_CELL_COUNT = env.int("CHANNEL_CELL_COUNT", 93794) @@ -148,9 +157,8 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env.str( - "SECRET_KEY", "django-insecure-jau(^3c!z+wl6#zsz!%bu1$v7ks48dosj1#=l=^+58)r1y2n8b" -) +# This must be set via the SECRET_KEY environment variable in production. +SECRET_KEY = env.str("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.bool("DEBUG", True)