diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d417593cc..295dcf69f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -173,16 +173,16 @@ jobs: uses: tj-actions/changed-files@v41 with: files: | - webapp/src/cljc/lipas/data/loi.cljc + webapp/src/cljc/lipas/schema/lois.cljc - name: Generate JSON schema if: steps.changed-files.outputs.any_changed == 'true' - run: lein run -m lipas.data.loi > loi-schema.json + run: lein run -m lipas.schema.lois > loi-schema.json working-directory: ./webapp - name: Generate CSV if: steps.changed-files.outputs.any_changed == 'true' - run: lein run -m lipas.data.loi csv > loi-schema.csv + run: lein run -m lipas.schema.lois csv > loi-schema.csv working-directory: ./webapp - name: Deploy JSON schema to Gist diff --git a/docker-compose.yml b/docker-compose.yml index 353ba0b53..9d2dd4252 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: proxy-local: extends: service: proxy-base + ports: + - '80:80' + - '443:443' + - '444:444' links: - mapproxy - logstash diff --git a/nginx/proxy_local.conf b/nginx/proxy_local.conf index e9e58f77e..4d79b34fd 100644 --- a/nginx/proxy_local.conf +++ b/nginx/proxy_local.conf @@ -106,5 +106,31 @@ server { location / { try_files $uri $uri/ /index.html =404; } +} + +server { + listen 444 ssl; + ssl_certificate /certs/server.crt; + ssl_certificate_key /certs/server.key; + + # Make search engines ignore anything returned from this proxy + add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; + + server_name lipas-dev.cc.jyu.fi; + + gzip on; + gzip_vary on; + gzip_http_version 1.0; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types application/javascript application/json application/transit+json; + # FIXME: Now the path prefix has to be same as in the Reitit tree + location /api-v2/ { + proxy_pass http://172.17.0.1:8091/api-v2/; + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + send_timeout 300; + } } diff --git a/webapp/dev/user.clj b/webapp/dev/user.clj index ef02284db..f46e35baf 100644 --- a/webapp/dev/user.clj +++ b/webapp/dev/user.clj @@ -64,6 +64,7 @@ (reindex-search!) (reindex-analytics!) (reset-admin-password! "kissa13") + (reset-password! "valtteri.harmainen@gmail.com" "kissa13") (require '[migratus.core :as migratus]) (migratus/create nil "activities_status" :sql) @@ -134,7 +135,7 @@ "Legacy lipas supports only these. Others will be treated as strings" {"boolean" "boolean" "numeric" "numberic" - "string" "string"}) + "string" "string"}) (doseq [[legacy-prop-k prop-k] legacy-mapping] (println @@ -151,7 +152,7 @@ (get-in prop-types/all [prop-k :description :en]) (name legacy-prop-k)))) - (doseq [p new-props + (doseq [p new-props [type-code m] types/all] (when (contains? (set (keys (:props m))) p) (println (str "-- Type " type-code)) @@ -167,4 +168,94 @@ (name (legacy-mapping-reverse p))) (get-in types/all [type-code :props p :priority]))))) + (require '[malli.provider :as mp]) + (require '[lipas.data.types :as types]) + (require '[lipas.backend.core :as core]) + + (def all-sites (atom {})) + + (doseq [type-code (keys types/all)] + (let [sites (core/get-sports-sites-by-type-code (db) type-code)] + (swap! all-sites (fn [m] + (reduce (fn [res site] (assoc res (:lipas-id site) site)) + m + sites))))) + + (mp/provide (vals @all-sites)) + (def schema *1) + + (require '[lipas.data.sports-sites :as ss]) + (require '[malli.core :as m]) + + (m/schema ss/base-schema) + + (m/validate ss/base-schema (-> @all-sites first second)) + + + (m/schema [:string {:min 1 :max 200}]) + (m/schema [:string {:min 1 :max 2048}]) + + + (require '[lipas.data.prop-types :as prop-types]) + + (m/schema [:set (into [:enum] (keys (:opts (:surface-material prop-types/all))))]) + + (require '[malli.dev]) + (malli.dev/start!) + + (require '[malli.error :as me]) + + (def results + (for [[lipas-id site] @all-sites] + [lipas-id (me/humanize (m/explain ss/base-schema site))])) + + (->> results + (filter #(some? (second %)))) + + (:activities (@all-sites 613971)) + + (count *1) + (@all-sites 613811) + + (require '[malli.json-schema :as mj]) + (mj/transform ss/base-schema) + + + (require '[lipas.data.types-new :as types-new]) + (require '[lipas.data.types-old :as types-old]) + + (require '[clojure.set :as set]) + + (def new-codes (set/difference + (set (keys types-new/all)) + (set (keys types-old/all)))) + + (-> types-new/all + (select-keys new-codes) + (->> (map (juxt first (comp :fi :name second))) + (sort-by first))) + + (def merged-codes (set/difference + (set (keys types-old/active)) + (set (keys types-new/active)))) + + (-> types-old/all + (select-keys merged-codes) + (->> (map (juxt first (comp :fi :name second))) + (sort-by first))) + + (def merged-to [101 103 106 203 4320 4510 4510]) + (-> types-new/all + (select-keys merged-to) + (->> (map (juxt first (comp :fi :name second))) + (sort-by first))) + + (require '[lipas.data.prop-types-new :as prop-types-new]) + (require '[lipas.data.prop-types-old :as prop-types-old]) + + (def new-prop-types (set/difference + (set (keys prop-types-new/all)) + (set (keys prop-types-old/all)))) + + ) diff --git a/webapp/project.clj b/webapp/project.clj index 521d74617..41db25d2f 100644 --- a/webapp/project.clj +++ b/webapp/project.clj @@ -7,8 +7,9 @@ [com.taoensso/timbre "4.10.0"] [com.cemerick/url "0.1.1"] [metosin/reitit "0.7.1"] + [metosin/ring-swagger-ui "5.18.2"] [metosin/spec-tools "0.10.7"] - [metosin/malli "0.16.2"] + [metosin/malli "0.17.0"] ;;; Frontend ;;; [thheller/shadow-cljs "2.28.16"] diff --git a/webapp/src/clj/lipas/backend/api/v2.clj b/webapp/src/clj/lipas/backend/api/v2.clj new file mode 100644 index 000000000..3b4c6ed96 --- /dev/null +++ b/webapp/src/clj/lipas/backend/api/v2.clj @@ -0,0 +1,347 @@ +(ns lipas.backend.api.v2 + (:require [clojure.string :as str] + [lipas.backend.core :as core] + [lipas.schema.common :as common-schema] + [lipas.schema.lois :as lois-schema] + [lipas.schema.sports-sites :as sports-sites-schema] + [lipas.schema.sports-sites.activities :as activities-schema] + [lipas.schema.sports-sites.types :as types-schema] + [lipas.schema.sports-sites.location :as location-schema] + [reitit.coercion.malli] + [reitit.openapi :as openapi] + [reitit.swagger-ui :as swagger-ui] + [ring.util.http-response :as resp])) + +(def sports-site-keys + "Publicly exposed top level keys." + ["lipas-id" + "status" + "event-date" + "admin" + "name" + "marketing-name" + "name-localized" + "construction-year" + "renovation-years" + "owner" + "location" + "properties" + "www" + "phone-number" + "type" + "activities" + "comment" + "circumstances"]) + +(defn decode-heisenparam + "Normalizes query parameters into a collection by handling different input formats: + + - Single value: Converts a scalar string into a vector by splitting on commas + - Multiple values: Preserves existing vector format + - Empty/nil: Returns the input unchanged + + Examples: + (decode-heisenparam \"a,b,c\") ;=> [\"a\" \"b\" \"c\"] + (decode-heisenparam [\"a\" \"b\"]) ;=> [\"a\" \"b\"] + (decode-heisenparam \"single\") ;=> [\"single\"] + (decode-heisenparam nil) ;=> nil + + Used for normalizing HTTP query parameters where the same parameter name + can appear multiple times or contain comma-separated values." + [x] + (cond-> x + (string? x) (str/split #","))) + +(def pagination-schema + [:map + [:current-page [:int {:min 1}]] + [:page-size [:int {:min 1}]] + [:total-items [:int {:min 0}]] + [:total-pages [:int {:min 0}]]]) + +(defn ->pagination + [{:keys [page page-size total-items] + :or {page 1 page-size 10}}] + {:current-page page + :page-size page-size + :total-items total-items + :total-pages (-> total-items (/ page-size) Math/ceil int)}) + +(defn ->sports-sites-query + [{:keys [page page-size statuses type-codes city-codes admins owners activities] + :or {page-size 10}}] + {:from (* (dec page) page-size) + :size page-size + :track_total_hits 50000 + :_source {:includes sports-site-keys} + :query {:bool + {:must (cond-> [] + statuses (conj {:terms {:status.keyword statuses}}) + type-codes (conj {:terms {:type.type-code type-codes}}) + city-codes (conj {:terms {:location.city.city-code city-codes}}) + admins (conj {:terms {:admin.keyword admins}}) + owners (conj {:terms {:owner.keyword owners}}) + activities (into (for [a activities] + {:exists {:field (str "activities." a)}})))}}}) + +(defn ->lois-query + [{:keys [page page-size statuses types categories] + :or {page-size 10}}] + {:from (* (dec page) page-size) + :size page-size + :track_total_hits 50000 + :query {:bool + {:must (cond-> [] + statuses (conj {:terms {:status.keyword statuses}}) + types (conj {:terms {:loi-type.keyword types}}) + categories (conj {:terms {:loi-category.keyword categories}}))}}}) + +(defn routes [{:keys [db search] :as _ctx}] + (let [ui-handler (swagger-ui/create-swagger-ui-handler + {:url "/api-v2/openapi.json"})] + ["/api-v2" + {:openapi + {:id :api-v2 + + :info {:title "LIPAS API V2" + :summary "API for Finnish sports and recreational facility database LIPAS" + :description "The LIPAS system provides comprehensive data about sports and recreational facilities in Finland. The API is organized into three main sections: + +**Sports Sites** +The core entities of LIPAS. Each sports facility is classified using a hierarchical type system, where specific facility types belong to subcategories within seven main categories. Each facility type has its own specific set of properties and a defined geometry type (Point, LineString, or Polygon) that describes its spatial representation. + +**Sports Site Categories** +Access to the hierarchical type classification system used for categorizing sports facilities. + +**Locations of Interest** +Additional non-facility entities in LIPAS, that complement the sports facility data." + + :contact + {:name "Support, feature requests and bug reports" + :url "https://github.com/lipas-liikuntapaikat/lipas/issues" + :email "lipasinfo@jyu.fi"} + + :license + {:name "Creative Commons Attribution 4.0 International" + :identifier "CC-BY-SA-4.0" + :url "https://creativecommons.org/licenses/by-sa/4.0/"}} + + ;; These get merged in a wild way + #_#_:tags [{:name "Sports Sites" + :description "The core entities of LIPAS."} + {:name "Sports Site Categories" + :description "Hierarchical categorization of sports facilities"} + {:name "Locations of Interest" + :description "Additional non-facility entities in LIPAS"}] + + } + ;; The regular handle is still using swagger-spec, so :openapi :id doesn't hide + ;; these routes from that. + :swagger {:id :hide-from-default} + :coercion reitit.coercion.malli/coercion} + + ["/sports-site-categories" + {:tags ["Sports Site Categories"]} + + ["" + {:get + {:summary "Get all sports site categories" + :handler + (fn [_req] + {:status 200 + :body (core/get-categories)}) + + :responses {200 {:body [:sequential #'types-schema/type]}}}}] + + ["/{type-code}" + {:get + {:summary "Get sports site category by type code" + :handler + (fn [req] + (let [type-code (-> req :parameters :path :type-code)] + {:status 200 + :body (core/get-category type-code)})) + + :parameters + {:path [:map [:type-code {:description (-> types-schema/type-code + second + :description)} + #'types-schema/type-code]]} + + :responses {200 {:body #'types-schema/type}}}}]] + + ["/sports-sites" + {:tags ["Sports Sites"]} + + ["" + {:get + {:summary "Get a paginated list of sports sites" + :handler (fn [req] + (tap> (:parameters req)) + (let [params (:query (:parameters req)) + query (->sports-sites-query params) + _ (tap> query) + results (core/search search query)] + (tap> results) + {:status 200 + :body + {:items (-> results :body :hits :hits (->> (keep :_source))) + :pagination (let [total-items (-> results :body :hits :total :value)] + (->pagination (assoc params :total-items total-items)))}})) + + :parameters {:query + [:map + + [:page {:optional true} + [:int {:min 1 :json-schema/default 1}]] + + [:page-size {:optional true} + [:int {:min 1 :max 100 :json-schema/default 10}]] + + [:statuses + {:optional true + :decode/string decode-heisenparam + :json-schema/default #{"active" "out-of-service-temporarily"}} + #'common-schema/statuses] + + [:city-codes + {:optional true + :decode/string decode-heisenparam + :description (-> location-schema/city-codes + second + :description)} + #'location-schema/city-codes] + + [:type-codes + {:optional true + :decode/string decode-heisenparam + :description (-> types-schema/type-codes + second + :description)} + #'types-schema/type-codes] + + [:admins + {:optional true + :decode/string decode-heisenparam + :description (-> sports-sites-schema/admins + second + :description)} + #'sports-sites-schema/admins] + + [:owners + {:optional true + :decode/string decode-heisenparam} + #'sports-sites-schema/owners] + + [:activities + {:optional true + :description (-> activities-schema/activities + second + :description) + :decode/string decode-heisenparam} + #'activities-schema/activities]]} + + :responses {200 {:body [:map + [:items + [:vector {:title "SportSites"} + #'sports-sites-schema/sports-site]] + [:pagination {:title "Pagination"} + pagination-schema]]}}}}] + + ["/{lipas-id}" + {:get + {:summary "Get sports facility by lipas-id" + + :handler (fn [req] + (tap> (:parameters req)) + (let [lipas-id (-> req :parameters :path :lipas-id)] + {:status 200 + :body (doto (core/get-sports-site db lipas-id) tap>)})) + + :parameters {:path [:map + [:lipas-id + {:description "Lipas-id of the sports facility"} + #'sports-sites-schema/lipas-id]]} + + :responses {200 {:body #'sports-sites-schema/sports-site}}}}]] + + ["/lois" + {:tags ["Locations of Interest"] + } + + ["" + {:get {:summary "Get a paginated list of Locations of Interest" + :handler (fn [req] + (tap> (:parameters req)) + (let [params (:query (:parameters req)) + query (->lois-query params) + _ (tap> query) + results (core/search-lois* search query)] + (tap> results) + {:status 200 + :body + {:items (-> results :body :hits :hits (->> (keep :_source))) + :pagination (let [total-items (-> results :body :hits :total :value)] + (->pagination (assoc params :total-items total-items)))}})) + + :parameters {:query + [:map + + [:page {:optional true} + [:int {:min 1 :json-schema/default 1}]] + + [:page-size {:optional true} + [:int {:min 1 :max 100 :json-schema/default 10}]] + + [:statuses + {:optional true + :decode/string decode-heisenparam + :json-schema/default #{"active" "out-of-service-temporarily"}} + #'common-schema/statuses] + + [:categories + {:optional true + :decode/string decode-heisenparam} + #'lois-schema/loi-categories] + + [:types + {:optional true + :decode/string decode-heisenparam} + #'lois-schema/loi-types]]} + + :responses {200 {:body [:map + [:items + [:vector {:title "LocationsOfInterest"} + #'lois-schema/loi]] + [:pagination {:title "Pagination"} + pagination-schema]]}}}}] + + ["/{loi-id}" + {:get + {:summary "Get Location of Interest by id" + + :handler (fn [req] + (tap> (:parameters req)) + (let [loi-id (-> req :parameters :path :loi-id)] + {:status 200 + :body (doto (core/get-loi search loi-id) tap>)})) + + :parameters {:path [:map + [:loi-id + {:description "UUID v4 of the Location if Interest"} + #'lois-schema/loi-id]]} + + :responses {200 {:body #'lois-schema/loi}}}}]] + + ["/openapi.json" + {:get + {:no-doc true + :swagger {:info {:title "Lipas-API v2"}} + :handler (openapi/create-openapi-handler)}}] + + ["/swagger-ui" + {:get {:no-doc true + :handler ui-handler}}] + ["/swagger-ui/*" + {:get {:no-doc true + :handler ui-handler}}]])) diff --git a/webapp/src/clj/lipas/backend/core.clj b/webapp/src/clj/lipas/backend/core.clj index 1d0988273..5617ec37b 100644 --- a/webapp/src/clj/lipas/backend/core.clj +++ b/webapp/src/clj/lipas/backend/core.clj @@ -799,6 +799,8 @@ (log/error ex) (db/update-elevation-status! db lipas-id "failed")))))) +;;; Newsletter ;;; + (defn get-newsletter [config] (newsletter/retrieve config)) @@ -843,6 +845,8 @@ :credentials-provider credentials-provider}))) +;;; LOI ;;; + (defn ->lois-es-query [{:keys [location loi-statuses]}] (let [lon (:lon location) @@ -875,14 +879,18 @@ query default-query))) -(defn search-lois +(defn search-lois* [{:keys [indices client]} es-query] (let [idx-name (get-in indices [:lois :search])] - (-> (search/search client idx-name es-query) - :body - :hits - :hits - (->> (map :_source))))) + (search/search client idx-name es-query))) + +(defn search-lois + [search es-query] + (-> (search-lois* search es-query) + :body + :hits + :hits + (->> (map :_source)))) (defn get-loi [{:keys [indices client]} loi-id] @@ -928,7 +936,18 @@ [{:keys [_filename _data _user] :as params}] (utp-cms/upload-image! params)) +;;; Types ;;; + +(defn get-categories + [] + (map types/->type (vals types/active))) + +(defn get-category + [type-code] + (types/->type (types/active type-code))) + (comment + (get-categories) (require '[lipas.backend.config :as config]) (def db-spec (:db config/default-config)) (def admin (get-user db-spec "admin@lipas.fi")) diff --git a/webapp/src/clj/lipas/backend/handler.clj b/webapp/src/clj/lipas/backend/handler.clj index 52d606c41..2ab8f59df 100644 --- a/webapp/src/clj/lipas/backend/handler.clj +++ b/webapp/src/clj/lipas/backend/handler.clj @@ -1,6 +1,7 @@ (ns lipas.backend.handler (:require [clojure.java.io :as io] [clojure.spec.alpha :as s] + [lipas.backend.api.v2 :as v2] [lipas.backend.core :as core] [lipas.backend.jwt :as jwt] [lipas.backend.middleware :as mw] @@ -717,7 +718,8 @@ {:status 200 :body (core/search-lois-with-params search body-params)})}}] - (ptv-handler/routes ctx)]] + (ptv-handler/routes ctx)] + (v2/routes ctx)] {:data {:coercion reitit.coercion.spec/coercion diff --git a/webapp/src/cljc/lipas/data/activities.cljc b/webapp/src/cljc/lipas/data/activities.cljc index f05dcef42..2a0565025 100644 --- a/webapp/src/cljc/lipas/data/activities.cljc +++ b/webapp/src/cljc/lipas/data/activities.cljc @@ -2,8 +2,10 @@ (:require #?(:clj [cheshire.core :as json]) #?(:clj [clojure.data.csv :as csv]) - #?(:clj [clojure.string :as str]) + [clojure.string :as str] [lipas.data.materials :as materials] + [lipas.data.types :as types] + [lipas.schema.common :as common-schema] [lipas.utils :as utils] [malli.core :as m] [malli.json-schema :as json-schema] @@ -11,48 +13,22 @@ (defn collect-schema [m] - (into [:map] (map (juxt first (constantly {:optional true}) (comp :schema second)) m))) - -(def localized-string-schema - [:map - [:fi {:optional true} [:string]] - [:se {:optional true} [:string]] - [:en {:optional true} [:string]]]) - -(def number-schema - [:or [:int] [:double]]) - -(def percentage-schema - (let [props {:min 0 :max 100}] - [:or [:int props] [:double props]])) + (into [:map] (map (juxt first + (fn [m] + {:optional true + :description (get-in (second m) [:field :description :en])}) + (comp :schema second)) + m))) (def duration-schema [:map - [:min {:optional true} number-schema] - [:max {:optional true} number-schema] + [:min {:optional true} common-schema/number] + [:max {:optional true} common-schema/number] [:unit {:optional true} [:enum "days" "hours" "minutes"]]]) (def surface-material-schema [:sequential (into [:enum] (keys materials/surface-materials))]) -(def route-fcoll-schema - [:map - [:type [:enum "FeatureCollection"]] - [:features - [:sequential - [:map - [:id {:optional true} [:string]] - [:type [:enum "Feature"]] - [:properties {:optional true} [:map]] - [:geometry - [:map - [:type [:enum "LineString"]] - [:coordinates - [:sequential - [:or - [:tuple :double :double] - [:tuple :double :double :double]]]]]]]]]]) - (def contact-roles {"admin" {:fi "Ylläpitäjä" :se "Administratör" @@ -207,8 +183,8 @@ [:custom-rules {:optional true} [:sequential [:map - [:label {:optional true} localized-string-schema] - [:description {:optional true} localized-string-schema] + [:label {:optional true} common-schema/localized-string] + [:description {:optional true} common-schema/localized-string] [:value {:optional true} [:string {:min 2}]]]]]]) (def status-opts @@ -236,7 +212,7 @@ :opts status-opts}} :description-short - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "1-3 lauseen esittely kohteesta ja sen erityispiirteistä." @@ -247,7 +223,7 @@ :en "Overview"}}} :description-long - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Yleiskuvausta jatkava, laajempi kuvaus kohteesta ja sen ominaisuuksista" @@ -260,11 +236,11 @@ :contacts {:schema [:sequential [:map - [:organization {:optional true} localized-string-schema] - [:role {:optional true} (into [:enum] (keys contact-roles))] - [:email {:optional true} [:string]] - [:www {:optional true} [:string]] - [:phone-number {:optional true} [:string]]]] + [:organization {:optional true} common-schema/localized-string] + [:role {:optional true} [:sequential (into [:enum] (keys contact-roles))]] + [:email {:optional true} common-schema/localized-string] + [:www {:optional true} common-schema/localized-string] + [:phone-number {:optional true} common-schema/localized-string]]] :field {:type "contacts" :description {:fi "Syötä kohteesta vastaavien tahojen yhteystiedot" @@ -342,7 +318,7 @@ {:schema [:sequential [:map [:url [:string]] - [:description {:optional true} localized-string-schema]]] + [:description {:optional true} common-schema/localized-string]]] :field {:type "videos" :description {:fi "Lisää URL-linkki web-palvelussa olevaan kohteen maisemia, luontoa tai harrastamisen olosuhteita esittelevään videoon. Varmista, että sinulla on oikeus lisätä video." @@ -356,9 +332,9 @@ {:schema [:sequential [:map [:url [:string]] - [:description {:optional true} localized-string-schema] - [:alt-text {:optional true} localized-string-schema] - [:copyright {:optional true} localized-string-schema]]] + [:description {:optional true} common-schema/localized-string] + [:alt-text {:optional true} common-schema/localized-string] + [:copyright {:optional true} common-schema/localized-string]]] :field {:type "images" :description {:fi "Lisää kohteen maisemia, luontoa tai harrastamisen olosuhteita esitteleviä valokuvia. Voit lisätä vain kuvatiedostoja, et URL-kuvalinkkejä. Kelvollisia tiedostomuotoja ovat .jpg, .jpeg ja .png. Varmista, että sinulla on oikeus lisätä kuva." @@ -401,7 +377,7 @@ :en "Copyright information"}}}}}} :additional-info-link - {:schema [:string] + {:schema common-schema/localized-string :field {:type "text-field" :description {:fi "Linkki ulkoisella sivustolla sijaitsevaan laajempaan kohde-esittelyyn" @@ -412,7 +388,7 @@ :en "More information"}}} :rules - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Liikkumis- tai toimintaohjeet, joiden avulla ohjataan toimintaa. Tässä voidaan kertoa myös mahdollisista liikkumis- tai toimintarajoituksista." @@ -423,7 +399,7 @@ :en "Permits, regulations, instructions"}}} :arrival - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Eri kulkumuodoilla kohteeseen pääsyyn liittyvää tietoa. Esim. pysäköintialueet ja joukkoliikenneyhteydet." @@ -434,7 +410,7 @@ :en "Arrival to destination"}}} :accessibility - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Yleistä tietoa kohteen esteettömyydestä tai kuljettavuudesta" @@ -445,7 +421,7 @@ :en "Accessibility"}}} :highlights - {:schema [:sequential localized-string-schema] + {:schema [:sequential common-schema/localized-string] :field {:type "textlist" :description {:fi "Syötä 2-6 konkreettista kohteen erityispiirrettä, jotka täydentävät yleiskuvausta. Syötä yksi kohokohta kerrallaan. Käytä isoa Alkukirjainta." @@ -638,23 +614,23 @@ (mu/dissoc :rules)) [:map [:id [:string]] - [:geometries route-fcoll-schema] + [:geometries common-schema/line-string-feature-collection] [:accessibility-categorized {:optional true} [:map - [:mobility-impaired {:optional true} localized-string-schema] - [:hearing-impaired {:optional true} localized-string-schema] - [:visually-impaired {:optional true} localized-string-schema] - [:developmentally-disabled {:optional true} localized-string-schema]]] - [:route-name {:optional true} localized-string-schema] + [:mobility-impaired {:optional true} common-schema/localized-string] + [:hearing-impaired {:optional true} common-schema/localized-string] + [:visually-impaired {:optional true} common-schema/localized-string] + [:developmentally-disabled {:optional true} common-schema/localized-string]]] + [:route-name {:optional true} common-schema/localized-string] [:outdoor-recreation-activities {:optional true} - [:sequential [:enum (keys outdoor-recreation-routes-activities)]]] + [:sequential (into [:enum] (keys outdoor-recreation-routes-activities))]] [:duration {:optional true} duration-schema] [:travel-direction {:optional true} [:enum "clockwise" "counter-clockwise"]] - [:route-marking {:optional true} localized-string-schema] + [:route-marking {:optional true} common-schema/localized-string] [:rules-structured {:optional true} rules-structured-schema] - [:route-length-km {:optional true} number-schema] + [:route-length-km {:optional true} common-schema/number] [:surface-material {:optional true} surface-material-schema] - [:accessibility-classification + [:accessibility-classification {:optional true} (into [:enum] (keys accessibility-classification))] [:independent-entity {:optional true} [:boolean]] pilgrimage-key-schema])] @@ -826,7 +802,7 @@ :en "Select all the surface materials on the route"}}} #_#_:latest-updates - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Tähän joku seliteteksti" @@ -969,23 +945,23 @@ common-route-props-schema [:map [:id [:string]] - [:geometries route-fcoll-schema] - [:route-name {:optional true} localized-string-schema] + [:geometries common-schema/line-string-feature-collection] + [:route-name {:optional true} common-schema/localized-string] [:cycling-activities {:optional true} [:sequential (into [:enum] (keys cycling-activities))]] [:cycling-difficulty {:optional true} (into [:enum] (keys cycling-difficulty))] - [:cycling-route-difficulty {:optional true} localized-string-schema] + [:cycling-route-difficulty {:optional true} common-schema/localized-string] [:duration {:optional true} duration-schema] - [:food-and-water {:optional true} localized-string-schema] - [:accommodation {:optional true} localized-string-schema] - [:good-to-know {:optional true} localized-string-schema] - [:route-notes {:optional true} localized-string-schema] - [:route-length-km {:optional true} number-schema] + [:food-and-water {:optional true} common-schema/localized-string] + [:accommodation {:optional true} common-schema/localized-string] + [:good-to-know {:optional true} common-schema/localized-string] + [:route-notes {:optional true} common-schema/localized-string] + [:route-length-km {:optional true} common-schema/number] [:surface-material {:optional true} surface-material-schema] - [:unpaved-percentage {:optional true} percentage-schema] - [:trail-percentage {:optional true} percentage-schema] - [:cyclable-percentage {:optional true} percentage-schema] + [:unpaved-percentage {:optional true} common-schema/percentage] + [:trail-percentage {:optional true} common-schema/percentage] + [:cyclable-percentage {:optional true} common-schema/percentage] pilgrimage-key-schema])] :field {:type "routes" @@ -1305,8 +1281,8 @@ common-route-props-schema [:map [:id [:string]] - [:geometries route-fcoll-schema] - [:route-name {:optional true} localized-string-schema] + [:geometries common-schema/line-string-feature-collection] + [:route-name {:optional true} common-schema/localized-string] [:paddling-activities {:optional true} [:sequential (into [:enum] (keys paddling-activities))]] [:paddling-route-type {:optional true} @@ -1315,8 +1291,8 @@ [:sequential (into [:enum] (keys paddling-properties))]] [:paddling-difficulty (into [:enum] (keys paddling-difficulty))] [:travel-direction {:optional true} [:enum "clockwise" "counter-clockwise"]] - [:safety {:optional true} localized-string-schema] - [:good-to-know {:optional true} localized-string-schema] + [:safety {:optional true} common-schema/localized-string] + [:good-to-know {:optional true} common-schema/localized-string] [:duration {:optional true} duration-schema] pilgrimage-key-schema])] :field @@ -1491,7 +1467,7 @@ :opts birdwatching-types}} :birdwatching-habitat - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Suokohde, …" @@ -1502,7 +1478,7 @@ :en "Habitat"}}} :birdwatching-character - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Muutonseurantakohde, …" @@ -1525,7 +1501,7 @@ :opts birdwatching-seasons}} :birdwatching-species - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Kahlaajat, Vesilinnut, Petolinnut, …" @@ -1651,7 +1627,7 @@ :opts fishing-species}} :fish-population - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Kirjoita tähän kuvaus kohteen vesistössä esiintyvästä kalastosta." @@ -1663,7 +1639,7 @@ :fishing-methods - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Tietoa mm. kohteessa kalastukseen vaikuttavista erityispiirteistä, toimivista välinevalinnoista yms." @@ -1686,7 +1662,7 @@ :opts fishing-permit-opts}} :fishing-permit-additional-info - {:schema localized-string-schema + {:schema common-schema/localized-string :field {:type "textarea" :description {:fi "Syötä tähän tarvittaessa lisätietoa kalastuslupia koskevista muista asioista" @@ -1710,10 +1686,10 @@ :accessibility-categorized {:schema [:map - [:mobility-impaired {:optional true} localized-string-schema] - [:hearing-impaired {:optional true} localized-string-schema] - [:visually-impaired {:optional true} localized-string-schema] - [:developmentally-disabled {:optional true} localized-string-schema]] + [:mobility-impaired {:optional true} common-schema/localized-string] + [:hearing-impaired {:optional true} common-schema/localized-string] + [:visually-impaired {:optional true} common-schema/localized-string] + [:developmentally-disabled {:optional true} common-schema/localized-string]] :field {:type "accessibility" diff --git a/webapp/src/cljc/lipas/data/loi.cljc b/webapp/src/cljc/lipas/data/loi.cljc index 5150e9257..ab2e5a70a 100644 --- a/webapp/src/cljc/lipas/data/loi.cljc +++ b/webapp/src/cljc/lipas/data/loi.cljc @@ -1,9 +1,5 @@ (ns lipas.data.loi - (:require - #?(:clj [cheshire.core :as json]) - #?(:clj [clojure.data.csv :as csv]) - [lipas.data.status :as status] - [malli.json-schema :as json-schema])) + (:require [lipas.data.status :as status])) (def statuses status/statuses) @@ -443,119 +439,7 @@ :geom-type "Polygon" :props (merge common-props protected-area-fields)}}}}) -(def point-fcoll-schema - [:map - [:type [:enum "FeatureCollection"]] - [:features - [:sequential - [:map - [:id {:optional true} [:string]] - [:type [:enum "Feature"]] - [:properties {:optional true} [:map]] - [:geometry - [:map - [:type [:enum "Point"]] - [:coordinates - [:or - [:tuple :double :double] - [:tuple :double :double :double]]]]]]]]]) - -(def schema - (into [:or] - (for [[cat-k cat-v] categories - [_type-k type-v] (:types cat-v)] - (into - [:map {:description (str cat-k " > " (:value type-v)) - :title (-> type-v :label :fi)} - [:id [:string]] - [:event-date [:string]] - [:created-at [:string]] - [:geometries point-fcoll-schema] - [:status (into [:enum] (keys statuses))] - [:loi-category [:enum cat-k]] - [:loi-type [:enum (:value type-v)]]] - (for [[prop-k prop-v] (:props type-v)] - [prop-k {:optional true} (:schema prop-v)]))))) - -(defn gen-json-schema - [] - (-> schema - json-schema/transform - #?(:clj(json/encode {:pretty true}) - :cljs clj->js) - println)) - -(declare gen-csv) - -#?(:clj - (defn gen-csv - [] - (->> - (for [[category-code category] categories - [_ type] (:types category) - [prop-k prop] (:props type)] - [category-code - (get-in category [:label :fi]) - (get-in category [:label :se]) - (get-in category [:label :en]) - (get-in category [:description :fi]) - (get-in category [:description :se]) - (get-in category [:description :en]) - (:value type) - (get-in type [:label :fi]) - (get-in type [:label :se]) - (get-in type [:label :en]) - (get-in type [:description :fi]) - (get-in type [:description :se]) - (get-in type [:description :en]) - (name prop-k) - (get-in prop [:field :label :fi]) - (get-in prop [:field :label :se]) - (get-in prop [:field :label :en]) - (get-in prop [:field :description :fi]) - (get-in prop [:field :description :se]) - (get-in prop [:field :description :en])]) - (into [["kategoria" - "kategoria nimi fi" - "kategoria nimi se" - "kategoria nimi en" - "kategoria kuvaus fi" - "kategoria kuvaus se" - "kategoria kuvaus en" - "tyyppi" - "tyyppi nimi fi" - "tyyppi nimi se" - "tyyppi nimi en" - "tyyppi kuvaus fi" - "tyyppi kuvaus se" - "tyyppi kuvaus en" - "ominaisuus" - "ominaisuus nimi fi" - "ominaisuus nimi se" - "ominaisuus nimi en" - "ominaisuus kuvaus fi" - "ominaisuus kuvaus se" - "ominaisuus kuvaus en"]]) - (csv/write-csv *out*)))) - -(comment - (gen-json-schema) - - (json-schema/transform [:tuple :double :double]) - ;; => {:type "array", - ;; :items [{:type "number"} {:type "number"}], - ;; :additionalItems false} - - - (gen-csv) - ) - (def types (->> categories vals (mapcat :types) (into {}))) - -(defn -main [& args] - (if (= "csv" (first args)) - (gen-csv) - (gen-json-schema))) diff --git a/webapp/src/cljc/lipas/data/prop_types.cljc b/webapp/src/cljc/lipas/data/prop_types.cljc index 35a80236e..51f311a2b 100644 --- a/webapp/src/cljc/lipas/data/prop_types.cljc +++ b/webapp/src/cljc/lipas/data/prop_types.cljc @@ -2,7 +2,7 @@ "Type codes went through a major overhaul in the summer of 2024. This namespace represents the changes made." (:require - [lipas.data.types :as types])) + [lipas.data.materials :as materials])) (def all {:height-m @@ -34,6 +34,7 @@ {:name {:fi "Pintamateriaali", :se "Ytmaterial", :en "Surface material"}, :data-type "enum-coll", + :opts materials/surface-materials :description {:fi "Liikunta-alueiden pääasiallinen pintamateriaali - tarkempi kuvaus liikuntapaikan eri tilojen pintamateriaalista voidaan antaa pintamateriaalin lisätietokentässä", @@ -817,7 +818,7 @@ :se "Ange antalet delar som utrymmet kan delas in i", :en "Enter the number of sections into which the space can be divided"}, - :data-type "number", + :data-type "numeric", :description {:fi "Onko tila jaettavissa osiin esim. jakoseinien tai -verhojen avulla", @@ -1815,6 +1816,11 @@ :se "Löpbanans, rundbanans el.dyl. längd i meter", :en "The length of the running track, cycling track, etc., in meters"}}}) -(def used - (let [used (set (mapcat (comp keys :props second) types/all))] - (select-keys all used))) +(def schemas + (into {} (for [[k m] all] + [k (case (:data-type m) + "string" [:string] + "numeric" number? + "boolean" [:boolean] + "enum" (into [:enum] (keys (:opts m))) + "enum-coll" [:sequential (into [:enum] (keys (:opts m)))])]))) diff --git a/webapp/src/cljc/lipas/data/sports_sites.cljc b/webapp/src/cljc/lipas/data/sports_sites.cljc index fb966aab7..3c2df7581 100644 --- a/webapp/src/cljc/lipas/data/sports_sites.cljc +++ b/webapp/src/cljc/lipas/data/sports_sites.cljc @@ -18,3 +18,4 @@ {:fi "Salibandykenttä" :en "Floorball field" :se "Innebandyplan"}}) + diff --git a/webapp/src/cljc/lipas/data/types.cljc b/webapp/src/cljc/lipas/data/types.cljc index cc4f76f95..7da780f96 100644 --- a/webapp/src/cljc/lipas/data/types.cljc +++ b/webapp/src/cljc/lipas/data/types.cljc @@ -3,6 +3,7 @@ (:require [lipas.utils :as utils] [lipas.data.types-old :as old] + [lipas.data.prop-types :as prop-types] #_[lipas.data.types-new :as new] )) @@ -32,3 +33,24 @@ (def sub-category-by-fi-name (utils/index-by (comp :fi :name) (vals sub-categories))) + +(defn ->type + [m] + (-> m + (update :main-category (fn [id] (-> id + main-categories + (dissoc :ptv)))) + (update :sub-category (fn [id] (-> id + sub-categories + (dissoc :ptv :main-category)))) + + (update :props (fn [m] + (->> m + (reduce-kv (fn [res k v] + (conj res (merge v {:key k} (prop-types/all k)))) + []) + (sort-by :priority utils/reverse-cmp)))))) + +(def used-prop-types + (let [used (set (mapcat (comp keys :props second) all))] + (select-keys prop-types/all used))) diff --git a/webapp/src/cljc/lipas/data/types_old.cljc b/webapp/src/cljc/lipas/data/types_old.cljc index b77159962..6e9922c8f 100644 --- a/webapp/src/cljc/lipas/data/types_old.cljc +++ b/webapp/src/cljc/lipas/data/types_old.cljc @@ -4391,3 +4391,6 @@ (def sub-category-by-fi-name (utils/index-by (comp :fi :name) (vals sub-categories))) + +(def active + (reduce-kv (fn [m k v] (if (not= "active" (:status v)) (dissoc m k) m)) all all)) diff --git a/webapp/src/cljc/lipas/schema/common.cljc b/webapp/src/cljc/lipas/schema/common.cljc new file mode 100644 index 000000000..4c38c402d --- /dev/null +++ b/webapp/src/cljc/lipas/schema/common.cljc @@ -0,0 +1,85 @@ +(ns lipas.schema.common + (:require [lipas.data.status :as status])) + +(def -iso8601-pattern #"^(?:[1-9]\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z$") + +(def iso8601-timestamp [:re {:description "ISO 8601 timestamp in UTC timezone"} + -iso8601-pattern]) + +(def status (into [:enum] (keys status/statuses))) +(def statuses [:set status]) +;; https://github.com/metosin/malli/issues/670 +(def number number?) +(def percentage [number? {:min 0 :max 100}]) +(def -uuid-pattern #"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +(def uuid [:re {:description "UUID v4 string"} -uuid-pattern]) + +(def localized-string + [:map + [:fi {:optional true :description "Finnish translation"} [:string]] + [:se {:optional true :description "Swedish translation"} [:string]] + [:en {:optional true :description "English translation"} [:string]]]) + +(def coordinates + [:vector {:min 2 + :max 3 + :description "WGS84 Lon, Lat and optional altitude in meters"} + number?]) + +(def point-geometry + [:map {:description "GeoJSON Point geometry"} + [:type [:enum "Point"]] + [:coordinates #'coordinates]]) + +(def line-string-geometry + [:map {:description "GeoJSON LineString geometry"} + [:type [:enum "LineString"]] + [:coordinates [:vector #'coordinates]]]) + +(def polygon-geometry + [:map {:description "GeoJSON Polygon geometry"} + [:type [:enum "Polygon"]] + [:coordinates [:vector [:vector #'coordinates]]]]) + +(def point-feature + [:map {:description "GeoJSON Feature with required Point geometry."} + [:type [:enum "Feature"]] + [:geometry #'point-geometry] + [:properties {:optional true} [:map]]]) + +(def line-string-feature + [:map {:description "GeoJSON Feature with required LineString geometry."} + [:type [:enum "Feature"]] + [:geometry #'line-string-geometry] + [:properties {:optional true} [:map]]]) + +(def polygon-feature + [:map {:description "GeoJSON Feature with required Polygon geometry."} + [:type [:enum "Feature"]] + [:geometry #'polygon-geometry] + [:properties {:optional true} [:map]]]) + +(def point-feature-collection + [:map {:description "GeoJSON FeatureCollection with required Point geometries."} + [:type [:enum "FeatureCollection"]] + [:features + [:sequential point-feature]]]) + +(def line-string-feature-collection + [:map {:description "GeoJSON FeatureCollection with required LineString geometries."} + [:type [:enum "FeatureCollection"]] + [:features + [:sequential + #'line-string-feature]]]) + +(def polygon-feature-collection + [:map {:description "GeoJSON FeatureCollection with required Polygon geometries."} + [:type [:enum "FeatureCollection"]] + [:features + [:sequential + #'polygon-feature]]]) + +(comment + (require '[malli.core :as m]) + (m/schema point-geometry) + (m/schema line-string-geometry)) diff --git a/webapp/src/cljc/lipas/schema/core.cljc b/webapp/src/cljc/lipas/schema/core.cljc index 49fca3b0f..d53685879 100644 --- a/webapp/src/cljc/lipas/schema/core.cljc +++ b/webapp/src/cljc/lipas/schema/core.cljc @@ -16,6 +16,7 @@ [lipas.data.prop-types :as prop-types] [lipas.data.reminders :as reminders] [lipas.data.sports-sites :as sports-sites] + [lipas.data.status :as status] [lipas.data.swimming-pools :as swimming-pools] [lipas.data.types :as sports-site-types] [lipas.reports :as reports] @@ -2245,9 +2246,9 @@ (s/def :lipas.loi/event-date :lipas/timestamp) (s/def :lipas.loi/status - (st/spec {:spec (into #{} (keys loi/statuses)) + (st/spec {:spec (into #{} (keys status/statuses)) :swagger/type "string" - :swagger/enum (keys loi/statuses)})) + :swagger/enum (keys status/statuses)})) (s/def :lipas.loi/loi-category (st/spec {:spec (into #{} (keys loi/categories)) diff --git a/webapp/src/cljc/lipas/schema/lois.cljc b/webapp/src/cljc/lipas/schema/lois.cljc new file mode 100644 index 000000000..ffb6ba863 --- /dev/null +++ b/webapp/src/cljc/lipas/schema/lois.cljc @@ -0,0 +1,124 @@ +(ns lipas.schema.lois + (:require #?(:clj [cheshire.core :as json]) + #?(:clj [clojure.data.csv :as csv]) + [lipas.data.loi :as loi] + [lipas.schema.common :as common] + [malli.json-schema :as json-schema])) + +(def loi-id common/uuid) +(def loi-category (into [:enum] (keys loi/categories))) +(def loi-categories [:set loi-category]) + +(def loi-type (into [:enum] (for [[_ category] loi/categories + [_ type] (:types category)] + (:value type)))) + +(def loi-types [:set loi-type]) + +(def loi + (into [:multi {:title "LocationOfInterest" + :dispatch :loi-type}] + (for [[cat-k cat-v] loi/categories + [_type-k type-v] (:types cat-v)] + [(:value type-v) + (into + [:map {:description (str cat-k " > " (:value type-v)) + :title (-> type-v :label :en)} + [:id loi-id] + [:event-date {:description "Timestamp when this information became valid (ISO 8601, UTC time zone)"} + #'common/iso8601-timestamp] + #_[:created-at [:string]] + [:geometries (case (:geom-type type-v) + ("Polygon") #'common/polygon-feature-collection + ("LineString") #'common/line-string-feature-collection + #'common/point-feature-collection)] + [:status common/status] + [:loi-category {:description "The category of the type of the Location of Interest"} + [:enum cat-k]] + [:loi-type {:description "The type of the Location of Interest"} + [:enum (:value type-v)]]] + (for [[prop-k prop-v] (:props type-v)] + [prop-k {:optional true} (:schema prop-v)]))]))) + +(comment + (require '[malli.core :as m]) + (m/schema loi) + ) + +(defn gen-json-schema + [] + (-> loi + json-schema/transform + #?(:clj(json/encode {:pretty true}) + :cljs clj->js) + println)) + +(declare gen-csv) + +#?(:clj + (defn gen-csv + [] + (->> + (for [[category-code category] loi/categories + [_ type] (:types category) + [prop-k prop] (:props type)] + [category-code + (get-in category [:label :fi]) + (get-in category [:label :se]) + (get-in category [:label :en]) + (get-in category [:description :fi]) + (get-in category [:description :se]) + (get-in category [:description :en]) + (:value type) + (get-in type [:label :fi]) + (get-in type [:label :se]) + (get-in type [:label :en]) + (get-in type [:description :fi]) + (get-in type [:description :se]) + (get-in type [:description :en]) + (name prop-k) + (get-in prop [:field :label :fi]) + (get-in prop [:field :label :se]) + (get-in prop [:field :label :en]) + (get-in prop [:field :description :fi]) + (get-in prop [:field :description :se]) + (get-in prop [:field :description :en])]) + (into [["kategoria" + "kategoria nimi fi" + "kategoria nimi se" + "kategoria nimi en" + "kategoria kuvaus fi" + "kategoria kuvaus se" + "kategoria kuvaus en" + "tyyppi" + "tyyppi nimi fi" + "tyyppi nimi se" + "tyyppi nimi en" + "tyyppi kuvaus fi" + "tyyppi kuvaus se" + "tyyppi kuvaus en" + "ominaisuus" + "ominaisuus nimi fi" + "ominaisuus nimi se" + "ominaisuus nimi en" + "ominaisuus kuvaus fi" + "ominaisuus kuvaus se" + "ominaisuus kuvaus en"]]) + (csv/write-csv *out*)))) + +(comment + (gen-json-schema) + + (json-schema/transform [:tuple :double :double]) + ;; => {:type "array", + ;; :items [{:type "number"} {:type "number"}], + ;; :additionalItems false} + + + (gen-csv) + ) + +(defn -main [& args] + (if (= "csv" (first args)) + (gen-csv) + (gen-json-schema))) diff --git a/webapp/src/cljc/lipas/schema/sports_sites.cljc b/webapp/src/cljc/lipas/schema/sports_sites.cljc new file mode 100644 index 000000000..3606a39f5 --- /dev/null +++ b/webapp/src/cljc/lipas/schema/sports_sites.cljc @@ -0,0 +1,178 @@ +(ns lipas.schema.sports-sites + (:require [lipas.data.admins :as admins] + [lipas.data.owners :as owners] + [lipas.data.activities :as activities] + [lipas.data.prop-types :as prop-types] + [lipas.schema.sports-sites.activities :as activities-schema] + [lipas.schema.sports-sites.circumstances :as circumstances-schema] + [lipas.schema.sports-sites.location :as location-schema] + [lipas.schema.common :as common] + [lipas.data.types :as types] + [lipas.schema.core :as specs] + [lipas.utils :as utils] + [malli.core :as m] + [malli.util :as mu])) + +(def lipas-id + [:int {:min 0 :label "Lipas-id" :description "Unique identifier of sports facility in LIPAS system."}]) + +(def owner + (into [:enum {:title "Onwer" + :description "Owner entity of the sports facility."}] + (keys owners/all))) + +(def owners + [:set {:title "Admins" + :description (-> owner second :description)} + #'owner]) + +(def admin + (into [:enum {:title "Admin" + :description "Administrative entity of the sports facility."}] + (keys admins/all))) + +(def admins + [:set {:title "Admins" + :description (-> admin second :description)} + #'admin]) + +(def name [:string {:description "The official name of the sports facility" + :min 2 + :max 100}]) + +(def marketing-name [:string {:min 2 + :max 100 + :description "Marketing name or common name of the sports facility."}]) + +(def name-localized + [:map {:description "The official name of the sports facility localized."} + [:se {:optional true :description "Swedish translation of the official name of the sports facility."} + [:string {:min 2 :max 100}]] + [:en {:optional true :description "English translation of the official name of the sports facility."} + [:string {:min 2 :max 100}]]]) + +(def email [:re {:description "Email address of the sports facility."} + specs/email-regex]) + +(def www [:string {:description "Website of the sports facility." + :min 1 + :max 500}]) + +(def reservation-link [:string {:description "Link to external booking system." + :min 1 + :max 500}]) + +(def phone-number [:string {:description "Phone number of the sports facility" + :min 1 + :max 50}]) + +(def comment [:string {:description "Additional information." + :min 1 + :max 2048}]) + +(def construction-year [:int {:description "Year of construction of the sports facility" + :min 1800 + :max (+ 10 utils/this-year)}]) + +(def renovation-years [:sequential {:description "Years of major renovation of the sports facility"} + [:int {:min 1800 :max (+ 10 utils/this-year)}]]) + +(defn make-sports-site-schema [{:keys [title + type-codes + description + extras-schema + location-schema]}] + ;; TODO audit + (mu/merge + [:map + {:title title + :description description + :closed false} + [:lipas-id #'lipas-id] + [:event-date {:description "Timestamp when this information became valid (ISO 8601, UTC time zone)"} + #'common/iso8601-timestamp] + [:status #'common/status] + [:name #'name] + [:marketing-name {:optional true} #'marketing-name] + [:name-localized {:optional true} #'name-localized] + [:owner #'owner] + [:admin #'admin] + [:email {:optional true} #'email] + [:www {:optional true} #'www] + [:reservations-link {:optional true} #'reservation-link] + [:phone-number {:optional true} #'phone-number] + [:comment {:optional true} #'comment] + [:construction-year {:optional true} #'construction-year] + [:renovation-years {:optional true} #'renovation-years] + [:type + [:map + [:type-code (into [:enum] type-codes)]]] + [:location location-schema]] + extras-schema)) + +(def sports-site + (into [:multi {:description "The core entity of LIPAS. Properties, geometry type and additional attributes vary based on the type of the sports facility." + :dispatch (fn [x] + (-> x :type :type-code))}] + (for [[type-code {:keys [geometry-type props] :as x}] (sort-by key types/all) + :let [activity (get activities/by-type-code type-code) + activity-key (some-> activity :value keyword) + floorball? (= 2240 type-code)]] + [type-code (make-sports-site-schema + {:title (str type-code " - " (:en (:name x))) + :description (get-in x [:description :en]) + :type-codes #{type-code} + :location-schema (case geometry-type + "Point" #'location-schema/point-location + "LineString" #'location-schema/line-string-location + "Polygon" #'location-schema/polygon-location) + :extras-schema (cond-> [:map] + (seq props) + (conj [:properties + {:optional true} + (into [:map] + (for [[k schema] (select-keys prop-types/schemas (keys props))] + [k {:optional true + :description (get-in prop-types/all [k :description :en])} + schema]))]) + + floorball? + (conj [:circumstances + {:optional true + :description "Floorball information"} + #'circumstances-schema/floorball]) + + activity + (conj [:activities + {:optional true + :description "Enriched content for Luontoon.fi service."} + [:map + [activity-key + {:optional true} + (case activity-key + :outdoor-recreation-areas #'activities-schema/outdoor-recreation-areas + :outdoor-recreation-facilities #'activities-schema/outdoor-recreation-facilities + :outdoor-recreation-routes #'activities-schema/outdoor-recreation-routes + :cycling #'activities-schema/cycling + :paddling #'activities-schema/paddling + :birdwatching #'activities-schema/birdwatching + :fishing #'activities-schema/fishing)]]]))})]))) + +#_(comment + (mu/get sports-site 101) + + (m/validate [:vector sports-site] + [{:lipas-id 1 + :status "active" + :name "foo" + :owner "city" + :admin "city-sports" + :location {:city {:city-code 5} + :address "foo" + :postal-code "00100" + :postal-office "foo" + :geometries {:type "FeatureCollection" + :features [{:type "Feature" + :geometry {:type "Point" + :coordinates [0.0 0.0]}}]}} + :type {:type-code 1530}}])) diff --git a/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc b/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc new file mode 100644 index 000000000..8810ca015 --- /dev/null +++ b/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc @@ -0,0 +1,48 @@ +(ns lipas.schema.sports-sites.activities + (:require [clojure.string :as str] + [lipas.data.activities :as activities-data] + [lipas.data.types :as types] + [malli.util :as mu])) + +(def activity (into [:enum] (keys activities-data/activities))) +(def activities + [:set {:title "Activities" + :description "Enriched activity related content for Luontoon.fi service. Certain sports facility types may contain data about activities that can be practiced at the facility."} + activity]) + +(defn -append-description + [schema {:keys [type-codes label]}] + (let [s (str (:en label) + " is an activity associated with facility types " + (str/join ", " (for [[k m] (select-keys types/all type-codes)] + (str k " " (get-in m [:name :en])))) + ". Enriched activity information is collected for Luontoon.fi service.")] + (mu/update-properties schema assoc :description s))) + +(def fishing (-append-description + activities-data/fishing-schema + activities-data/fishing)) + +(def outdoor-recreation-areas (-append-description + activities-data/outdoor-recreation-areas-schema + activities-data/outdoor-recreation-areas)) + +(def outdoor-recreation-routes (-append-description + activities-data/outdoor-recreation-routes-schema + activities-data/outdoor-recreation-routes)) + +(def outdoor-recreation-facilities (-append-description + activities-data/outdoor-recreation-facilities-schema + activities-data/outdoor-recreation-facilities)) + +(def cycling (-append-description + activities-data/cycling-schema + activities-data/cycling)) + +(def paddling (-append-description + activities-data/paddling-schema + activities-data/paddling)) + +(def birdwatching (-append-description + activities-data/birdwatching-schema + activities-data/birdwatching)) diff --git a/webapp/src/cljc/lipas/schema/sports_sites/circumstances.cljc b/webapp/src/cljc/lipas/schema/sports_sites/circumstances.cljc new file mode 100644 index 000000000..6091b209f --- /dev/null +++ b/webapp/src/cljc/lipas/schema/sports_sites/circumstances.cljc @@ -0,0 +1,64 @@ +(ns lipas.schema.sports-sites.circumstances) + +;; TODO check fields that are exposed via public API before release +(def floorball + [:map {:title "FloorballCircumstances" + :description "Enriched floorball facility information"} + [:storage-capacity {:optional true} :string] + [:roof-trusses-operation-model {:optional true} :string] + [:general-information {:optional true} :string] + [:corner-pieces-count {:optional true} :int] + [:audience-toilets-count {:optional true} :int] + [:bus-park-capacity {:optional true} :int] + [:wifi-capacity-sufficient-for-streaming? {:optional true} :boolean] + [:first-aid-comment {:optional true} :string] + [:fixed-cameras? {:optional true} :boolean] + [:stretcher? {:optional true} :boolean] + [:field-level-loading-doors? {:optional true} :boolean] + [:car-parking-economics-model {:optional true} :string] + [:vip-area? {:optional true} :boolean] + [:separate-referee-locker-room? {:optional true} :boolean] + [:audit-date {:optional true} :string] + [:led-screens-or-surfaces-for-ads? {:optional true} :boolean] + [:saunas-count {:optional true} :int] + [:camera-stands? {:optional true} :boolean] + [:wired-microfone-quantity {:optional true} :int] + [:locker-rooms-count {:optional true} :int] + [:car-parking-capacity {:optional true} :int] + [:broadcast-car-park? {:optional true} :boolean] + [:press-conference-space? {:optional true} :boolean] + [:open-floor-space-length-m {:optional true} number?] + [:wifi-available? {:optional true} :boolean] + [:goal-shrinking-elements-count {:optional true} :int] + [:scoreboard-count {:optional true} :int] + [:restaurateur-contact-info {:optional true} :string] + [:vip-area-comment {:optional true} :string] + [:cafe-or-restaurant-has-exclusive-rights-for-products? + {:optional true} + :boolean] + [:gym? {:optional true} :boolean] + [:ticket-sales-operator {:optional true} :string] + [:side-training-space? {:optional true} :boolean] + [:locker-room-quality-comment {:optional true} :string] + [:roof-trusses? {:optional true} :boolean] + [:detached-chair-quantity {:optional true} :int] + [:conference-space-total-capacity-person {:optional true} :int] + [:iff-certification-stickers-in-goals? {:optional true} :boolean] + [:electrical-plan-available? {:optional true} :boolean] + [:audio-mixer-available? {:optional true} :boolean] + [:iff-certified-rink? {:optional true} :boolean] + [:teams-using {:optional true} :string] + [:loading-equipment-available? {:optional true} :boolean] + [:doping-test-facilities? {:optional true} :boolean] + [:wireless-microfone-quantity {:optional true} :int] + [:defibrillator? {:optional true} :boolean] + [:open-floor-space-width-m {:optional true} number?] + [:cafeteria-and-restaurant-capacity-person {:optional true} :int] + [:speakers-aligned-towards-stands? {:optional true} :boolean] + [:conference-space-quantity {:optional true} :int] + [:three-phase-electric-power? {:optional true} :boolean] + [:roof-trusses-capacity-kg {:optional true} :int] + [:open-floor-space-area-m2 {:optional true} number?] + [:detached-tables-quantity {:optional true} :int] + [:available-goals-count {:optional true} :int] + [:player-entrance {:optional true} :string]]) diff --git a/webapp/src/cljc/lipas/schema/sports_sites/location.cljc b/webapp/src/cljc/lipas/schema/sports_sites/location.cljc new file mode 100644 index 000000000..502092e6c --- /dev/null +++ b/webapp/src/cljc/lipas/schema/sports_sites/location.cljc @@ -0,0 +1,67 @@ +(ns lipas.schema.sports-sites.location + (:require [lipas.schema.core :as specs] + [lipas.data.cities :as cities] + [lipas.schema.common :as common] + [malli.util :as mu])) + +(def address + [:string {:description "Street address of the sports facility." + :min 1 + :max 200}]) + +(def postal-code + [:re {:description "Postal code of the address of the sports facility."} + specs/postal-code-regex]) + +(def postal-office + [:string {:description "Postal office of the address of the sports facility." + :min 1 + :max 100}]) + +(def neighborhood + [:string {:description "Neighborhood or common name for the area of the location." + :min 1 + :max 100}]) + + +(def city-code + (into [:enum {:title "CityCode" + :description "Official municipality identifier https://stat.fi/fi/luokitukset/kunta/kunta_1_20240101"}] + (sort (keys cities/by-city-code)))) + +(def city-codes + [:set {:title "CityCodes" + :description (-> city-code second :description)} + city-code]) + +(defn make-location-schema [feature-schema geom-type] + [:map {:description (str "Location of the sports facility with required " + geom-type + " feature.")} + [:city + [:map + [:city-code #'city-code] + [:neighborhood {:optional true} #'neighborhood]]] + [:address #'address] + [:postal-code #'postal-code] + [:postal-office {:optional true} #'postal-code] + [:geometries + [:map + [:type [:enum "FeatureCollection"]] + [:features + [:vector feature-schema]]]]]) + +(def line-string-feature-props + [:map + [:name {:optional true} :string] + #_[:lipas-id {:optional true} #'lipas-id] + [:type-code {:optional true} :int] + [:route-part-difficulty {:optional true} :string] + [:travel-direction {:optional true} :string]]) + +(def line-string-feature + (mu/assoc common/line-string-feature :properties line-string-feature-props)) + +(def point-location (make-location-schema common/point-feature "Point")) +(def line-string-location (make-location-schema line-string-feature "LineString")) +(def polygon-location (make-location-schema common/polygon-feature "Polygon")) diff --git a/webapp/src/cljc/lipas/schema/sports_sites/types.cljc b/webapp/src/cljc/lipas/schema/sports_sites/types.cljc new file mode 100644 index 000000000..0148f12d0 --- /dev/null +++ b/webapp/src/cljc/lipas/schema/sports_sites/types.cljc @@ -0,0 +1,55 @@ +(ns lipas.schema.sports-sites.types + (:require [lipas.data.prop-types :as prop-types] + [lipas.data.types :as types] + [lipas.schema.common :as common])) + +(def tags [:sequential [:string {:min 2 :max 100}]]) + +(def type-code + (into [:enum {:title "TypeCode" + :description "Sports facility type according to LIPAS classification https://www.jyu.fi/fi/file/lipas-tyyppikoodit-2024"}] + (keys types/active))) + +(def type-codes + [:set {:title "TypeCodes" + :description (-> type-code second :description)} + #'type-code]) + +(def main-category (into [:enum] (keys types/main-categories))) +(def sub-category (into [:enum] (keys types/sub-categories))) +(def prop-type-key (into [:enum] (keys prop-types/all))) + +(def type + [:map {:description "Metadata definition for a specific sports facility type in LIPAS"} + [:name #'common/localized-string] + [:description #'common/localized-string] + [:tags {:optional true} + [:map + [:fi {:optional true} #'tags] + [:se {:optional true} #'tags] + [:en {:optional true} #'tags]]] + [:type-code #'type-code] + [:main-category + [:map + [:type-code #'main-category] + [:name #'common/localized-string]]] + [:sub-category + [:map + [:type-code #'sub-category] + [:name #'common/localized-string]]] + [:geometry-type [:enum "Point" "LineString" "Polygon"]] + [:props + [:vector + [:map + [:priority [:int]] + [:key #'prop-type-key] + [:name #'common/localized-string] + [:description #'common/localized-string] + [:data-type [:enum "numeric" "boolean" "enum" "enum-coll" "string"]] + [:opts {:optional true} + [:map-of [:string {:min 2 :max 200}] #'common/localized-string]]]]]]) + +(comment + (require '[malli.core :as m]) + (m/schema type) + ) diff --git a/webapp/src/cljs/lipas/ui/sports_sites/db.cljs b/webapp/src/cljs/lipas/ui/sports_sites/db.cljs index 5f3d77859..e119e0620 100644 --- a/webapp/src/cljs/lipas/ui/sports_sites/db.cljs +++ b/webapp/src/cljs/lipas/ui/sports_sites/db.cljs @@ -23,7 +23,7 @@ :types types/all :active-types types/active - :prop-types prop-types/used + :prop-types types/used-prop-types :materials materials/all :building-materials materials/building-materials diff --git a/webapp/test/clj/lipas/backend/handler_test.clj b/webapp/test/clj/lipas/backend/handler_test.clj index 3b6024bb0..863659fd7 100644 --- a/webapp/test/clj/lipas/backend/handler_test.clj +++ b/webapp/test/clj/lipas/backend/handler_test.clj @@ -6,6 +6,7 @@ [lipas.backend.core :as core] [lipas.backend.jwt :as jwt] [lipas.data.loi :as loi] + [lipas.data.status :as status] [lipas.schema.core] [lipas.seed :as seed] [lipas.test-utils :refer [->transit <-transit <-json ->json app search db] :as tu] @@ -119,7 +120,7 @@ _ (doseq [loi lois-with-every-status] (core/index-loi! search loi :sync)) responses (mapv #(app (-> (mock/request :get (str "/api/lois/status/" %)) (mock/content-type "application/json"))) - (keys loi/statuses)) + (keys status/statuses)) bodies (mapv (comp first <-json :body) responses)] (is (= "planning" (:status (nth bodies 0)))) (is (= "planned" (:status (nth bodies 1)))) diff --git a/webapp/test/clj/lipas/timestamps_test.clj b/webapp/test/clj/lipas/timestamps_test.clj new file mode 100644 index 000000000..60c36948f --- /dev/null +++ b/webapp/test/clj/lipas/timestamps_test.clj @@ -0,0 +1,57 @@ +(ns lipas.timestamps-test + (:require [clojure.test :refer [deftest testing is]] + [lipas.schema.common :as common-schema])) + +(def iso8601-pattern common-schema/-iso8601-pattern) + +(deftest iso8601-timestamp-test + (testing "Valid UTC timestamps" + (is (re-matches iso8601-pattern "2024-12-27T13:25:00Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.123Z")) + (is (re-matches iso8601-pattern "2024-01-01T00:00:00Z")) + (is (re-matches iso8601-pattern "2024-12-31T23:59:59Z")) + (is (re-matches iso8601-pattern "2024-02-29T00:00:00Z")) ; Valid leap year + (is (re-matches iso8601-pattern "2000-02-29T00:00:00Z"))) ; Valid century leap year + + (testing "Invalid timestamps" + ; Non-UTC timestamps (should all fail) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00+02:00"))) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00-02:00"))) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00.123+02:00"))) + + ; Invalid dates + (is (nil? (re-matches iso8601-pattern "2024-13-01T00:00:00Z"))) ; Invalid month + (is (nil? (re-matches iso8601-pattern "2024-00-01T00:00:00Z"))) ; Invalid month + (is (nil? (re-matches iso8601-pattern "2024-12-32T00:00:00Z"))) ; Invalid day + (is (nil? (re-matches iso8601-pattern "2024-12-00T00:00:00Z"))) ; Invalid day + (is (nil? (re-matches iso8601-pattern "2023-02-29T00:00:00Z"))) ; Invalid leap year date + + ; Invalid times + (is (nil? (re-matches iso8601-pattern "2024-12-27T24:00:00Z"))) ; Invalid hour + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:60:00Z"))) ; Invalid minute + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:00:60Z"))) ; Invalid second + + ; Invalid formats + (is (nil? (re-matches iso8601-pattern "2024-12-27 12:00:00Z"))) ; Space instead of T + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:00:00"))) ; Missing Z + (is (nil? (re-matches iso8601-pattern "24-12-27T12:00:00Z"))) ; Two-digit year + (is (nil? (re-matches iso8601-pattern "2024/12/27T12:00:00Z")))) ; Wrong separators + + (testing "Edge cases for months with different lengths" + (is (re-matches iso8601-pattern "2024-01-31T00:00:00Z")) ; January 31 valid + (is (nil? (re-matches iso8601-pattern "2024-04-31T00:00:00Z"))) ; April 31 invalid + (is (re-matches iso8601-pattern "2024-04-30T00:00:00Z")) ; April 30 valid + (is (nil? (re-matches iso8601-pattern "2024-02-30T00:00:00Z"))) ; February 30 invalid + (is (re-matches iso8601-pattern "2024-06-30T00:00:00Z")) ; June 30 valid + (is (nil? (re-matches iso8601-pattern "2024-06-31T00:00:00Z")))) ; June 31 invalid + + (testing "Fractional seconds variations" + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.1Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.12Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.123Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.1234567890Z")))) + + +(comment + (clojure.test/run-tests *ns*) + )