Repository: data-for-change/anyway

Issue:
[Bug] Internal server error code 500 when accessing news-flash API
**Describe the bug**
Accessing the API with either:
  1. Non existent "source"
  2. One and only one of either "start_date" or "end_date" 
Results in an internal server error 500

**To Reproduce**
Steps to reproduce the behavior:
1. Access the news-flash API via:
  * https://www.anyway.co.il/api/news-flash?source=wallak
  * https://www.anyway.co.il/api/news-flash?start_date=484343
2. See error

**Expected behavior**
Receive 404 Not Found error (perhaps with an explanation?)

**Environment**
System: Linux




Discussion:

Resolving patch:
diff --git a/anyway/backend_constants.py b/anyway/backend_constants.py
--- a/anyway/backend_constants.py
+++ b/anyway/backend_constants.py
@@ -108,6 +108,22 @@ class ResolutionCategories(Enum):
         ResolutionCategories.SUBURBAN_ROAD,
     ]
 
+    class Source(Enum):
+        @classmethod
+        def _missing_(cls, value):
+            for member in cls:
+                if member.value == value.lower():
+                    return member
+        YNET = "ynet"
+        WALLA = "walla"
+        TWITTER = "twitter"
+
+    SUPPORTED_SOURCES: List[Source] = [
+        Source.YNET,
+        Source.WALLA,
+        Source.TWITTER,
+    ]
+
     # If in the future there will be a number of organizations or a need for a dynamic setting change, move this
     # data to a table in the DB.
     OR_YAROK_WIDGETS = [
diff --git a/anyway/flask_app.py b/anyway/flask_app.py
--- a/anyway/flask_app.py
+++ b/anyway/flask_app.py
@@ -99,6 +99,7 @@
     news_flash,
     news_flash_new,
     single_news_flash,
+    news_flash_v2,
     DEFAULT_LIMIT_REQ_PARAMETER,
     DEFAULT_OFFSET_REQ_PARAMETER,
 )
@@ -1161,6 +1162,8 @@ def load_identity_when_session_expires():
 )
 app.add_url_rule("/api/news-flash", endpoint=None, view_func=news_flash, methods=["GET"])
 
+app.add_url_rule("/api/news-flash_v2", endpoint=None, view_func=news_flash_v2, methods=["GET"])
+
 
 nf_parser = reqparse.RequestParser()
 nf_parser.add_argument("id", type=int, help="News flash id")
diff --git a/anyway/views/news_flash/api.py b/anyway/views/news_flash/api.py
--- a/anyway/views/news_flash/api.py
+++ b/anyway/views/news_flash/api.py
@@ -1,20 +1,54 @@
+# pylint: disable=no-name-in-module
+# pylint: disable=no-self-argument
+
 import datetime
 import json
 import logging
-from typing import List
 
-from flask import request, Response
-from sqlalchemy import and_, not_
+from typing import List, Optional
+
+from flask import request, Response, make_response, jsonify
+from sqlalchemy import and_, not_, or_
+
 
 from anyway.app_and_db import db
 from anyway.backend_constants import BE_CONST
 from anyway.models import NewsFlash
 from anyway.infographics_utils import is_news_flash_resolution_supported
 
+from pydantic import BaseModel, ValidationError, validator
+
 DEFAULT_OFFSET_REQ_PARAMETER = 0
 DEFAULT_LIMIT_REQ_PARAMETER = 100
 
 
+class NewsFlashQuery(BaseModel):
+
+    id: Optional[int]
+    road_number: Optional[int]
+    offset: Optional[int] = DEFAULT_OFFSET_REQ_PARAMETER
+    limit: Optional[int] = DEFAULT_LIMIT_REQ_PARAMETER
+    resolution: Optional[List[str]]
+    source: Optional[List[BE_CONST.Source]]
+    # Must set default value in order to allow access from "check_missing_date" validator when no value provided
+    # from the request
+    start_date: Optional[datetime.datetime] = None
+    end_date: Optional[datetime.datetime] = None
+
+    @validator("end_date", always=True)
+    def check_missing_date(cls, v, values):
+        if bool(v) != bool(values["start_date"]):
+            raise ValueError("Must provide both start and end date")
+        return v
+
+    @validator("resolution")
+    def check_supported_resolutions(cls, v):
+        supported_resolutions = get_supported_resolutions()
+        requested_resolutions = set([resolution.lower() for resolution in v])
+        if not requested_resolutions <= supported_resolutions:
+            raise ValueError(f"Resolution must be one of: {supported_resolutions}")
+        return requested_resolutions
+
 def news_flash():
     news_flash_id = request.values.get("id")
 
@@ -45,7 +79,26 @@ def news_flash():
 
     news_flashes_jsons = [n.serialize() for n in news_flashes]
     for news_flash in news_flashes_jsons:
-        set_display_source(news_flash, news_flash_id)
+        set_display_source(news_flash)
+    return Response(json.dumps(news_flashes_jsons, default=str), mimetype="application/json")
+
+
+def news_flash_v2():
+    requested_query_params = normalize_query(request.args)
+    try:
+        validated_query_params = NewsFlashQuery(**requested_query_params).dict(exclude_none=True)
+    except ValidationError as e:
+        return make_response(jsonify(e.errors()[0]["msg"]), 404)
+
+    if "id" in validated_query_params:
+        return get_news_flash_by_id(validated_query_params["id"])
+
+    query = gen_news_flash_query_v2(validated_query_params)
+    news_flashes = query.all()
+
+    news_flashes_jsons = [n.serialize() for n in news_flashes]
+    for news_flash in news_flashes_jsons:
+        set_display_source(news_flash)
     return Response(json.dumps(news_flashes_jsons, default=str), mimetype="application/json")
 
 
@@ -70,7 +123,7 @@ def news_flash_new(args: dict) -> List[dict]:
 
     news_flashes_jsons = [n.serialize() for n in news_flashes]
     for news_flash in news_flashes_jsons:
-        set_display_source(news_flash, news_flash_id)
+        set_display_source(news_flash)
     return news_flashes_jsons
 
 
@@ -131,12 +184,38 @@ def gen_news_flash_query(
     return query
 
 
-def set_display_source(news_flash, news_flash_id):
+def gen_news_flash_query_v2(valid_params: dict):
+    query = db.session.query(NewsFlash)
+    for param, value in valid_params.items():
+        if param == "road_number":
+            query = query.filter(NewsFlash.road1 == value)
+        if param == "source":
+            sources = [source.value for source in value]
+            query = query.filter(NewsFlash.source.in_(sources))
+        if param == "start_date":
+            query = query.filter(
+                NewsFlash.date >= value and NewsFlash.date <= valid_params["end_date"]
+            )
+        if param == "resolution":
+            query = filter_by_resolutions(query, value)
+    query = query.filter(
+        and_(
+            NewsFlash.accident == True,
+            not_(and_(NewsFlash.lat == 0, NewsFlash.lon == 0)),
+            not_(and_(NewsFlash.lat == None, NewsFlash.lon == None)),
+        )
+    ).order_by(NewsFlash.date.desc())
+    query = query.offset(valid_params["offset"])
+    query = query.limit(valid_params["limit"])
+    return query
+
+
+def set_display_source(news_flash):
     news_flash["display_source"] = BE_CONST.SOURCE_MAPPING.get(
         news_flash["source"], BE_CONST.UNKNOWN
     )
     if news_flash["display_source"] == BE_CONST.UNKNOWN:
-        logging.warning(f"Unknown source of news-flash with id {str(news_flash_id)}")
+        logging.warning(f"Unknown source of news-flash with id")
 
 
 def filter_by_timeframe(end_date, news_flash_obj, start_date):
@@ -153,3 +232,53 @@ def single_news_flash(news_flash_id: int):
             json.dumps(news_flash_obj.serialize(), default=str), mimetype="application/json"
         )
     return Response(status=404)
+
+
+def get_supported_resolutions() -> set:
+    return set([x.name.lower() for x in BE_CONST.SUPPORTED_RESOLUTIONS])
+
+
+def get_news_flash_by_id(id: int):
+    query = db.session.query(NewsFlash)
+    news_flash_with_id = query.filter(NewsFlash.id == id).first()
+    if news_flash_with_id is None:
+        return Response(status=404)
+    if not is_news_flash_resolution_supported(news_flash_with_id):
+        return Response("News flash location not supported", 406)
+    return Response(
+        json.dumps(news_flash_with_id.serialize(), default=str), mimetype="application/json"
+    )
+
+
+def filter_by_resolutions(query, resolutions: List[str]):
+    ands = []
+    if "suburban_road" in resolutions:
+        ands.append(
+            and_(
+                NewsFlash.resolution == BE_CONST.ResolutionCategories.SUBURBAN_ROAD.value,
+                NewsFlash.road_segment_name != None,
+            )
+        )
+    if "street" in resolutions:
+        ands.append(
+            and_(
+                NewsFlash.resolution == BE_CONST.ResolutionCategories.STREET.value,
+                NewsFlash.street1_hebrew != None,
+            )
+        )
+    if len(ands) > 1:
+        return query.filter(or_(*ands))
+    return query.filter(ands.pop())
+
+
+# Params that can take more than 1 value are kept as lists regardless of number of values supplied
+# in order to simplify dependent code.
+# Single valued params are converted to their value.
+def normalize_query_param(key, value: list):
+    multi_value_params = ["source", "resolution"]
+    return value if key in  multi_value_params else value.pop()
+
+
+def normalize_query(params: dict):
+    params_non_flat = params.to_dict(flat=False)
+    return {k: normalize_query_param(k, v) for k, v in params_non_flat.items()}


Test patch: