Skip to content

Commit 751d076

Browse files
authored
Merge pull request #32 from wmo-raf/forecast_post_api
enable adding city forecasts via post request
2 parents 2ccb36a + 027ca25 commit 751d076

24 files changed

+1799
-228
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,4 @@ cython_debug/
170170
.DS_Store
171171

172172
# End of https://www.toptal.com/developers/gitignore/api/django
173+
.vscode

forecastmanager/forecast_settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def effective_periods(self):
7171
return [
7272
{"label": period.label, "time": period.forecast_effective_time}
7373
for period in self.periods.all()]
74-
74+
7575
@property
7676
def weather_conditions_list(self):
7777
weather_conditions = self.weather_conditions.all()

forecastmanager/serializers.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from django.db import transaction
2+
from django.db.models import Q
3+
from uuid import UUID
14
from rest_framework import serializers
25

3-
from .models import City, Forecast
6+
from .forecast_settings import ForecastPeriod, WeatherCondition, ForecastDataParameters
7+
from .models import City, Forecast, CityForecast, DataValue
48

59

610
class CitySerializer(serializers.ModelSerializer):
@@ -24,3 +28,157 @@ def to_representation(self, instance):
2428
request = self.context.get("request")
2529

2630
return instance.get_geojson(request)
31+
32+
33+
class CityForecastPostSerializer(serializers.Serializer):
34+
city = serializers.CharField()
35+
condition = serializers.CharField()
36+
data_values = serializers.DictField(
37+
child=serializers.JSONField(),
38+
required=False,
39+
default=dict,
40+
)
41+
42+
def validate(self, attrs):
43+
city_ref = attrs.get("city")
44+
condition_ref = attrs.get("condition")
45+
data_values = attrs.get("data_values") or {}
46+
47+
city_query = Q(name__iexact=city_ref) | Q(slug__iexact=city_ref)
48+
try:
49+
city_uuid = UUID(str(city_ref))
50+
city_query = city_query | Q(id=city_uuid)
51+
except (ValueError, TypeError):
52+
pass
53+
54+
city_obj = City.objects.filter(city_query).first()
55+
56+
if not city_obj:
57+
raise serializers.ValidationError({"city": f"Unknown city: {city_ref}"})
58+
59+
condition_obj = WeatherCondition.objects.filter(
60+
Q(symbol__iexact=condition_ref) |
61+
Q(label__iexact=condition_ref) |
62+
Q(alias__iexact=condition_ref)
63+
).first()
64+
65+
if not condition_obj:
66+
raise serializers.ValidationError({"condition": f"Unknown weather condition: {condition_ref}"})
67+
68+
normalized_data_values = []
69+
for parameter_key, value in data_values.items():
70+
parameter_obj = ForecastDataParameters.objects.filter(
71+
Q(parameter__iexact=parameter_key) |
72+
Q(name__iexact=parameter_key)
73+
).first()
74+
75+
if not parameter_obj:
76+
raise serializers.ValidationError(
77+
{"data_values": f"Unknown parameter: {parameter_key}"}
78+
)
79+
80+
if value is None or value == "":
81+
continue
82+
83+
if parameter_obj.parameter_type == "numeric":
84+
try:
85+
float(value)
86+
except (TypeError, ValueError):
87+
raise serializers.ValidationError(
88+
{
89+
"data_values": (
90+
f"Invalid numeric value for '{parameter_key}': {value}"
91+
)
92+
}
93+
)
94+
95+
normalized_data_values.append({
96+
"parameter": parameter_obj,
97+
"value": str(value),
98+
})
99+
100+
attrs["city_obj"] = city_obj
101+
attrs["condition_obj"] = condition_obj
102+
attrs["resolved_data_values"] = normalized_data_values
103+
return attrs
104+
105+
106+
class ForecastPostSerializer(serializers.Serializer):
107+
forecast_date = serializers.DateField()
108+
effective_time = serializers.TimeField(help_text="Forecast effective time, e.g. '06:00:00'")
109+
source = serializers.ChoiceField(
110+
choices=[choice[0] for choice in Forecast.FORECAST_SOURCE_CHOICES],
111+
required=False,
112+
default="local",
113+
)
114+
replace_existing = serializers.BooleanField(required=False, default=True)
115+
city_forecasts = CityForecastPostSerializer(many=True)
116+
117+
def validate_city_forecasts(self, value):
118+
if not value:
119+
raise serializers.ValidationError("At least one city forecast is required.")
120+
121+
seen = set()
122+
for city_forecast in value:
123+
city_id = str(city_forecast["city_obj"].id)
124+
if city_id in seen:
125+
raise serializers.ValidationError(
126+
f"Duplicate city in payload: {city_forecast['city_obj'].name}"
127+
)
128+
seen.add(city_id)
129+
130+
return value
131+
132+
def validate(self, attrs):
133+
effective_time = attrs.get("effective_time")
134+
period_obj = ForecastPeriod.objects.filter(forecast_effective_time=effective_time).first()
135+
if not period_obj:
136+
raise serializers.ValidationError({"effective_time": f"No ForecastPeriod found for time {effective_time}"})
137+
attrs["effective_period"] = period_obj
138+
return attrs
139+
140+
@transaction.atomic
141+
def create(self, validated_data):
142+
city_forecasts = validated_data.pop("city_forecasts")
143+
replace_existing = validated_data.pop("replace_existing", True)
144+
effective_period = validated_data.pop("effective_period")
145+
146+
existing_forecasts = Forecast.objects.filter(
147+
forecast_date=validated_data["forecast_date"],
148+
effective_period=effective_period,
149+
)
150+
151+
if existing_forecasts.exists():
152+
if not replace_existing:
153+
raise serializers.ValidationError(
154+
"Forecast already exists for this date and effective period. "
155+
"Set replace_existing=true to overwrite it."
156+
)
157+
existing_forecasts.delete()
158+
159+
forecast = Forecast.objects.create(
160+
forecast_date=validated_data["forecast_date"],
161+
effective_period=effective_period,
162+
source=validated_data.get("source", "local")
163+
)
164+
165+
for city_forecast_data in city_forecasts:
166+
city_forecast = CityForecast.objects.create(
167+
parent=forecast,
168+
city=city_forecast_data["city_obj"],
169+
condition=city_forecast_data["condition_obj"],
170+
)
171+
172+
data_values = [
173+
DataValue(
174+
parent=city_forecast,
175+
parameter=data_value["parameter"],
176+
value=data_value["value"],
177+
)
178+
for data_value in city_forecast_data["resolved_data_values"]
179+
]
180+
181+
if data_values:
182+
DataValue.objects.bulk_create(data_values)
183+
184+
return forecast

forecastmanager/templatetags/forecastmanager_tags.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

forecastmanager/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .views import (
44
CityListView,
55
ForecastListView,
6+
ForecastPostView,
67
download_forecast_template,
78
weather_icons,
89
forecast_settings
@@ -11,7 +12,8 @@
1112
urlpatterns = [
1213
path('api/cities', CityListView.as_view(), name='cities-list'),
1314
path('api/forecasts', ForecastListView.as_view(), name='forecast-list'),
15+
path('api/forecasts/post', ForecastPostView.as_view(), name='forecast-post'),
1416
path('api/forecast-settings', forecast_settings, name='forecast-settings'),
1517
path('api/weather-icons', weather_icons, name='weather-icons'),
16-
path('api/forecast_templace.csv', download_forecast_template, name='download-forecast-template'),
18+
path('api/forecast_template.csv', download_forecast_template, name='download-forecast-template'),
1719
]

forecastmanager/views.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
from django.urls import reverse
88
from django.utils import timezone
99
from django_filters.rest_framework import DjangoFilterBackend
10+
from rest_framework import status
11+
from rest_framework.response import Response
12+
from rest_framework.views import APIView
1013
from rest_framework.generics import ListAPIView
1114
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
1215
from wagtail.api.v2.utils import get_full_url
1316

1417
from forecastmanager.models import City, Forecast
1518
from .forecast_settings import ForecastSetting
1619
from .forms import CityLoaderForm
17-
from .serializers import CitySerializer, ForecastSerializer
20+
from .serializers import CitySerializer, ForecastSerializer, ForecastPostSerializer
1821
from .utils import get_weather_condition_icons
1922

2023

@@ -49,6 +52,21 @@ def get_queryset(self):
4952
return queryset
5053

5154

55+
class ForecastPostView(APIView):
56+
permission_classes = [IsAuthenticated]
57+
58+
def post(self, request, *args, **kwargs):
59+
serializer = ForecastPostSerializer(data=request.data)
60+
serializer.is_valid(raise_exception=True)
61+
forecast = serializer.save()
62+
63+
response_data = {
64+
"message": "Forecast data pushed successfully.",
65+
"forecast": ForecastSerializer(forecast, context={"request": request}).data,
66+
}
67+
return Response(response_data, status=status.HTTP_201_CREATED)
68+
69+
5270
def download_forecast_template(request):
5371
response = HttpResponse(content_type='text/csv')
5472
response['Content-Disposition'] = 'attachment; filename="forecast_template.csv"'

sandbox/home/models.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,39 +20,18 @@ class HomePage(Page):
2020
]
2121

2222
def get_context(self, request, *args, **kwargs):
23-
context = super().get_context(request, *args, **kwargs)
24-
fm_settings = ForecastSetting.for_request(request)
25-
city_detail_page = fm_settings.weather_detail_page
26-
city_detail_page_url = None
27-
28-
if city_detail_page:
29-
city_detail_page = city_detail_page.specific
30-
city_detail_page_url = city_detail_page.get_full_url(request)
31-
city_detail_page_url = city_detail_page_url + city_detail_page.reverse_subpage("forecast_for_city")
32-
context.update({
33-
"city_detail_page_url": city_detail_page_url,
34-
})
35-
23+
context = super(HomePage, self).get_context(request, *args, **kwargs)
24+
3625
city_search_url = get_full_url(request, reverse("cities-list"))
3726
context.update({
3827
"city_search_url": city_search_url,
39-
"city_detail_page_url": city_detail_page_url
4028
})
41-
42-
default_city = fm_settings.default_city
43-
if not default_city:
44-
default_city = City.objects.first()
45-
46-
if default_city:
47-
default_city_forecasts = CityForecast.objects.filter(
48-
city=default_city,
49-
parent__forecast_date__gte=timezone.localtime()
50-
).order_by("parent__forecast_date")
51-
52-
context.update({
53-
"default_city_forecasts": default_city_forecasts
54-
})
55-
29+
30+
context.update({
31+
"home_weather_widget_url": get_full_url(request, reverse("home-weather-widget")),
32+
})
33+
34+
5635
return context
5736

5837

0 commit comments

Comments
 (0)