Merge branch 'master' of github.com:bornhack/bornhack-website
This commit is contained in:
commit
86ec617b71
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -6,4 +6,4 @@ db.sqlite3
|
||||||
*.pyc
|
*.pyc
|
||||||
venv/
|
venv/
|
||||||
environment_settings.py
|
environment_settings.py
|
||||||
|
elm-stuff/
|
||||||
|
|
5
schedule/Makefile
Normal file
5
schedule/Makefile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
all:
|
||||||
|
elm-make src/Main.elm --warn --output ../src/program/static/js/elm_based_schedule.js
|
||||||
|
|
||||||
|
debug:
|
||||||
|
elm-make src/Main.elm --debug --warn --output ../src/program/static/js/elm_based_schedule.js
|
22
schedule/elm-package.json
Normal file
22
schedule/elm-package.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"summary": "helpful summary of your project, less than 80 characters",
|
||||||
|
"repository": "https://github.com/user/project.git",
|
||||||
|
"license": "BSD3",
|
||||||
|
"source-directories": [
|
||||||
|
"src/"
|
||||||
|
],
|
||||||
|
"exposed-modules": [],
|
||||||
|
"dependencies": {
|
||||||
|
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
|
||||||
|
"elm-community/list-extra": "6.1.0 <= v < 7.0.0",
|
||||||
|
"elm-lang/core": "5.1.1 <= v < 6.0.0",
|
||||||
|
"elm-lang/html": "2.0.0 <= v < 3.0.0",
|
||||||
|
"elm-lang/navigation": "2.1.0 <= v < 3.0.0",
|
||||||
|
"elm-lang/websocket": "1.0.2 <= v < 2.0.0",
|
||||||
|
"evancz/elm-markdown": "3.0.2 <= v < 4.0.0",
|
||||||
|
"evancz/url-parser": "2.0.1 <= v < 3.0.0",
|
||||||
|
"justinmimbs/elm-date-extra": "2.0.3 <= v < 3.0.0"
|
||||||
|
},
|
||||||
|
"elm-version": "0.18.0 <= v < 0.19.0"
|
||||||
|
}
|
122
schedule/src/Decoders.elm
Normal file
122
schedule/src/Decoders.elm
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
module Decoders exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (Day, Speaker, Event, EventInstance, Model, Flags, Filter, Route(..), FilterType(..))
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Json.Decode exposing (int, string, float, list, bool, dict, Decoder, nullable)
|
||||||
|
import Json.Decode.Pipeline exposing (decode, required, optional, hardcoded)
|
||||||
|
import Date exposing (Date, Month(..))
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Date.Extra
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
|
||||||
|
|
||||||
|
-- DECODERS
|
||||||
|
|
||||||
|
|
||||||
|
type alias WebSocketAction =
|
||||||
|
{ action : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
webSocketActionDecoder : Decoder WebSocketAction
|
||||||
|
webSocketActionDecoder =
|
||||||
|
decode WebSocketAction
|
||||||
|
|> required "action" string
|
||||||
|
|
||||||
|
|
||||||
|
dayDecoder : Decoder Day
|
||||||
|
dayDecoder =
|
||||||
|
decode Day
|
||||||
|
|> required "day_name" string
|
||||||
|
|> required "iso" dateDecoder
|
||||||
|
|> required "repr" string
|
||||||
|
|
||||||
|
|
||||||
|
speakerDecoder : Decoder Speaker
|
||||||
|
speakerDecoder =
|
||||||
|
decode Speaker
|
||||||
|
|> required "name" string
|
||||||
|
|> required "slug" string
|
||||||
|
|> required "biography" string
|
||||||
|
|> optional "large_picture_url" (nullable string) Nothing
|
||||||
|
|> optional "small_picture_url" (nullable string) Nothing
|
||||||
|
|
||||||
|
|
||||||
|
eventDecoder : Decoder Event
|
||||||
|
eventDecoder =
|
||||||
|
decode Event
|
||||||
|
|> required "title" string
|
||||||
|
|> required "slug" string
|
||||||
|
|> required "abstract" string
|
||||||
|
|> required "speaker_slugs" (list string)
|
||||||
|
|> required "video_state" string
|
||||||
|
|> optional "video_url" (nullable string) Nothing
|
||||||
|
|> required "event_type" string
|
||||||
|
|
||||||
|
|
||||||
|
dateDecoder : Decoder Date
|
||||||
|
dateDecoder =
|
||||||
|
let
|
||||||
|
unpacked isoString =
|
||||||
|
isoString
|
||||||
|
|> Date.Extra.fromIsoString
|
||||||
|
|> Maybe.withDefault (Date.Extra.fromParts 1970 Jan 1 0 0 0 0)
|
||||||
|
in
|
||||||
|
Json.Decode.map unpacked string
|
||||||
|
|
||||||
|
|
||||||
|
eventInstanceDecoder : Decoder EventInstance
|
||||||
|
eventInstanceDecoder =
|
||||||
|
decode EventInstance
|
||||||
|
|> required "title" string
|
||||||
|
|> required "slug" string
|
||||||
|
|> required "id" int
|
||||||
|
|> required "url" string
|
||||||
|
|> required "event_slug" string
|
||||||
|
|> required "event_type" string
|
||||||
|
|> required "bg-color" string
|
||||||
|
|> required "fg-color" string
|
||||||
|
|> required "from" dateDecoder
|
||||||
|
|> required "to" dateDecoder
|
||||||
|
|> required "timeslots" float
|
||||||
|
|> required "location" string
|
||||||
|
|> required "location_icon" string
|
||||||
|
|> required "video_state" string
|
||||||
|
|> optional "video_url" (nullable string) Nothing
|
||||||
|
|> optional "is_favorited" (nullable bool) Nothing
|
||||||
|
|
||||||
|
|
||||||
|
eventLocationDecoder : Decoder FilterType
|
||||||
|
eventLocationDecoder =
|
||||||
|
decode LocationFilter
|
||||||
|
|> required "name" string
|
||||||
|
|> required "slug" string
|
||||||
|
|> required "icon" string
|
||||||
|
|
||||||
|
|
||||||
|
eventTypeDecoder : Decoder FilterType
|
||||||
|
eventTypeDecoder =
|
||||||
|
decode TypeFilter
|
||||||
|
|> required "name" string
|
||||||
|
|> required "slug" string
|
||||||
|
|> required "color" string
|
||||||
|
|> required "light_text" bool
|
||||||
|
|
||||||
|
|
||||||
|
initDataDecoder : Decoder (Flags -> Filter -> Location -> Route -> Bool -> Model)
|
||||||
|
initDataDecoder =
|
||||||
|
decode Model
|
||||||
|
|> required "days" (list dayDecoder)
|
||||||
|
|> required "events" (list eventDecoder)
|
||||||
|
|> required "event_instances" (list eventInstanceDecoder)
|
||||||
|
|> required "event_locations" (list eventLocationDecoder)
|
||||||
|
|> required "event_types" (list eventTypeDecoder)
|
||||||
|
|> required "speakers" (list speakerDecoder)
|
51
schedule/src/Main.elm
Normal file
51
schedule/src/Main.elm
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
module Main exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (..)
|
||||||
|
import Routing exposing (parseLocation)
|
||||||
|
import Update exposing (update)
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import WebSocketCalls exposing (sendInitMessage)
|
||||||
|
import Views exposing (view)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import WebSocket exposing (listen)
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Flags Model Msg
|
||||||
|
main =
|
||||||
|
Navigation.programWithFlags
|
||||||
|
OnLocationChange
|
||||||
|
{ init = init
|
||||||
|
, view = view
|
||||||
|
, update = update
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Flags -> Location -> ( Model, Cmd Msg )
|
||||||
|
init flags location =
|
||||||
|
let
|
||||||
|
currentRoute =
|
||||||
|
parseLocation location
|
||||||
|
|
||||||
|
emptyFilter =
|
||||||
|
Filter [] [] []
|
||||||
|
|
||||||
|
model =
|
||||||
|
Model [] [] [] [] [] [] flags emptyFilter location currentRoute False
|
||||||
|
in
|
||||||
|
model ! [ sendInitMessage flags.camp_slug flags.websocket_server ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
WebSocket.listen model.flags.websocket_server WebSocketPayload
|
18
schedule/src/Messages.elm
Normal file
18
schedule/src/Messages.elm
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
module Messages exposing (Msg(..))
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (Day, EventInstance, FilterType)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= NoOp
|
||||||
|
| WebSocketPayload String
|
||||||
|
| ToggleFilter FilterType
|
||||||
|
| OnLocationChange Location
|
||||||
|
| BackInHistory
|
179
schedule/src/Models.elm
Normal file
179
schedule/src/Models.elm
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
module Models exposing (..)
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Date exposing (Date, now)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
|
||||||
|
|
||||||
|
type alias EventSlug =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias EventInstanceSlug =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias SpeakerSlug =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias DaySlug =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias FilterQuery =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Route is defined here rather than in Routing.elm due to it being used in Model. If it were in Routing.elm we would have a circular dependency.
|
||||||
|
|
||||||
|
|
||||||
|
type Route
|
||||||
|
= OverviewRoute
|
||||||
|
| OverviewFilteredRoute FilterQuery
|
||||||
|
| DayRoute DaySlug
|
||||||
|
| EventRoute EventSlug
|
||||||
|
| SpeakerRoute SpeakerSlug
|
||||||
|
| NotFoundRoute
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ days : List Day
|
||||||
|
, events : List Event
|
||||||
|
, eventInstances : List EventInstance
|
||||||
|
, eventLocations : List FilterType
|
||||||
|
, eventTypes : List FilterType
|
||||||
|
, speakers : List Speaker
|
||||||
|
, flags : Flags
|
||||||
|
, filter : Filter
|
||||||
|
, location : Location
|
||||||
|
, route : Route
|
||||||
|
, dataLoaded : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Day =
|
||||||
|
{ day_name : String
|
||||||
|
, date : Date
|
||||||
|
, repr : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Speaker =
|
||||||
|
{ name : String
|
||||||
|
, slug : SpeakerSlug
|
||||||
|
, biography : String
|
||||||
|
, largePictureUrl : Maybe String
|
||||||
|
, smallPictureUrl : Maybe String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias EventInstance =
|
||||||
|
{ title : String
|
||||||
|
, slug : EventInstanceSlug
|
||||||
|
, id : Int
|
||||||
|
, url : String
|
||||||
|
, eventSlug : EventSlug
|
||||||
|
, eventType : String
|
||||||
|
, backgroundColor : String
|
||||||
|
, forgroundColor : String
|
||||||
|
, from : Date
|
||||||
|
, to : Date
|
||||||
|
, timeslots : Float
|
||||||
|
, location : String
|
||||||
|
, locationIcon : String
|
||||||
|
, videoState : String
|
||||||
|
, videoUrl : Maybe String
|
||||||
|
, isFavorited : Maybe Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Event =
|
||||||
|
{ title : String
|
||||||
|
, slug : EventSlug
|
||||||
|
, abstract : String
|
||||||
|
, speakerSlugs : List SpeakerSlug
|
||||||
|
, videoState : String
|
||||||
|
, videoUrl : Maybe String
|
||||||
|
, eventType : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Flags =
|
||||||
|
{ schedule_timeslot_length_minutes : Int
|
||||||
|
, schedule_midnight_offset_hours : Int
|
||||||
|
, ics_button_href : String
|
||||||
|
, camp_slug : String
|
||||||
|
, websocket_server : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FILTERS
|
||||||
|
|
||||||
|
|
||||||
|
type alias FilterName =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias FilterSlug =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias LocationIcon =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias TypeColor =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
type alias TypeLightText =
|
||||||
|
Bool
|
||||||
|
|
||||||
|
|
||||||
|
type FilterType
|
||||||
|
= TypeFilter FilterName FilterSlug TypeColor TypeLightText
|
||||||
|
| LocationFilter FilterName FilterSlug LocationIcon
|
||||||
|
| VideoFilter FilterName FilterSlug
|
||||||
|
|
||||||
|
|
||||||
|
type alias Filter =
|
||||||
|
{ eventTypes : List FilterType
|
||||||
|
, eventLocations : List FilterType
|
||||||
|
, videoRecording : List FilterType
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
unpackFilterType filter =
|
||||||
|
case filter of
|
||||||
|
TypeFilter name slug _ _ ->
|
||||||
|
( name, slug )
|
||||||
|
|
||||||
|
LocationFilter name slug _ ->
|
||||||
|
( name, slug )
|
||||||
|
|
||||||
|
VideoFilter name slug ->
|
||||||
|
( name, slug )
|
||||||
|
|
||||||
|
|
||||||
|
getSlugFromFilterType filter =
|
||||||
|
let
|
||||||
|
( _, slug ) =
|
||||||
|
unpackFilterType filter
|
||||||
|
in
|
||||||
|
slug
|
||||||
|
|
||||||
|
|
||||||
|
getNameFromFilterType filter =
|
||||||
|
let
|
||||||
|
( name, slug ) =
|
||||||
|
unpackFilterType filter
|
||||||
|
in
|
||||||
|
name
|
72
schedule/src/Routing.elm
Normal file
72
schedule/src/Routing.elm
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
module Routing exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (Route(..))
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
import UrlParser exposing (Parser, (</>), oneOf, map, top, s, string, parseHash)
|
||||||
|
|
||||||
|
|
||||||
|
{--
|
||||||
|
URLs to support:
|
||||||
|
|
||||||
|
- #/
|
||||||
|
This show the overview of the schedule
|
||||||
|
|
||||||
|
- #/?type={types},location={locations},video={not-to-be-recorded,to-be-recorded,has-recording}
|
||||||
|
This is the overview, just with filters enable
|
||||||
|
|
||||||
|
- #/day/{year}-{month}-{day}
|
||||||
|
Show a particular day
|
||||||
|
|
||||||
|
- #/event/{slug}
|
||||||
|
Show a particular event
|
||||||
|
|
||||||
|
--}
|
||||||
|
|
||||||
|
|
||||||
|
matchers : Parser (Route -> a) a
|
||||||
|
matchers =
|
||||||
|
oneOf
|
||||||
|
[ map OverviewRoute top
|
||||||
|
, map OverviewFilteredRoute (top </> string)
|
||||||
|
, map DayRoute (s "day" </> string)
|
||||||
|
, map EventRoute (s "event" </> string)
|
||||||
|
, map SpeakerRoute (s "speaker" </> string)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
parseLocation : Location -> Route
|
||||||
|
parseLocation location =
|
||||||
|
parseHash matchers location
|
||||||
|
|> Maybe.withDefault NotFoundRoute
|
||||||
|
|
||||||
|
|
||||||
|
routeToString : Route -> String
|
||||||
|
routeToString route =
|
||||||
|
let
|
||||||
|
parts =
|
||||||
|
case route of
|
||||||
|
OverviewRoute ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
OverviewFilteredRoute query ->
|
||||||
|
[ query ]
|
||||||
|
|
||||||
|
DayRoute iso ->
|
||||||
|
[ "day", iso ]
|
||||||
|
|
||||||
|
EventRoute slug ->
|
||||||
|
[ "event", slug ]
|
||||||
|
|
||||||
|
SpeakerRoute slug ->
|
||||||
|
[ "speaker", slug ]
|
||||||
|
|
||||||
|
NotFoundRoute ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
"#/" ++ String.join "/" parts
|
123
schedule/src/Update.elm
Normal file
123
schedule/src/Update.elm
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
module Update exposing (update)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (Model, Route(..), Filter, FilterType(..))
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Decoders exposing (webSocketActionDecoder, initDataDecoder, eventDecoder)
|
||||||
|
import Routing exposing (parseLocation)
|
||||||
|
import Views.FilterView exposing (parseFilterFromQuery, filterToQuery)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Json.Decode
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Navigation
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
NoOp ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
WebSocketPayload str ->
|
||||||
|
let
|
||||||
|
newModel =
|
||||||
|
case Json.Decode.decodeString webSocketActionDecoder str of
|
||||||
|
Ok webSocketAction ->
|
||||||
|
case webSocketAction.action of
|
||||||
|
"init" ->
|
||||||
|
case Json.Decode.decodeString initDataDecoder str of
|
||||||
|
Ok m ->
|
||||||
|
m model.flags model.filter model.location model.route True
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
model
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
model
|
||||||
|
|
||||||
|
( newModel_, _ ) =
|
||||||
|
update (OnLocationChange model.location) newModel
|
||||||
|
in
|
||||||
|
newModel_ ! []
|
||||||
|
|
||||||
|
ToggleFilter filter ->
|
||||||
|
let
|
||||||
|
currentFilter =
|
||||||
|
model.filter
|
||||||
|
|
||||||
|
newFilter =
|
||||||
|
case filter of
|
||||||
|
TypeFilter name slug color lightText ->
|
||||||
|
let
|
||||||
|
eventType =
|
||||||
|
TypeFilter name slug color lightText
|
||||||
|
in
|
||||||
|
{ currentFilter
|
||||||
|
| eventTypes =
|
||||||
|
if List.member eventType model.filter.eventTypes then
|
||||||
|
List.filter (\x -> x /= eventType) model.filter.eventTypes
|
||||||
|
else
|
||||||
|
eventType :: model.filter.eventTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationFilter name slug icon ->
|
||||||
|
let
|
||||||
|
eventLocation =
|
||||||
|
LocationFilter name slug icon
|
||||||
|
in
|
||||||
|
{ currentFilter
|
||||||
|
| eventLocations =
|
||||||
|
if List.member eventLocation model.filter.eventLocations then
|
||||||
|
List.filter (\x -> x /= eventLocation) model.filter.eventLocations
|
||||||
|
else
|
||||||
|
eventLocation :: model.filter.eventLocations
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoFilter name slug ->
|
||||||
|
let
|
||||||
|
videoRecording =
|
||||||
|
VideoFilter name slug
|
||||||
|
in
|
||||||
|
{ currentFilter
|
||||||
|
| videoRecording =
|
||||||
|
if List.member videoRecording model.filter.videoRecording then
|
||||||
|
List.filter (\x -> x /= videoRecording) model.filter.videoRecording
|
||||||
|
else
|
||||||
|
videoRecording :: model.filter.videoRecording
|
||||||
|
}
|
||||||
|
|
||||||
|
query =
|
||||||
|
filterToQuery newFilter
|
||||||
|
|
||||||
|
cmd =
|
||||||
|
Navigation.newUrl query
|
||||||
|
in
|
||||||
|
{ model | filter = newFilter } ! [ cmd ]
|
||||||
|
|
||||||
|
OnLocationChange location ->
|
||||||
|
let
|
||||||
|
newRoute =
|
||||||
|
parseLocation location
|
||||||
|
|
||||||
|
newFilter =
|
||||||
|
case newRoute of
|
||||||
|
OverviewFilteredRoute query ->
|
||||||
|
parseFilterFromQuery query model
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
model.filter
|
||||||
|
in
|
||||||
|
{ model | filter = newFilter, route = newRoute, location = location } ! []
|
||||||
|
|
||||||
|
BackInHistory ->
|
||||||
|
model ! [ Navigation.back 1 ]
|
60
schedule/src/Views.elm
Normal file
60
schedule/src/Views.elm
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
module Views exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (..)
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Views.DayPicker exposing (dayPicker)
|
||||||
|
import Views.DayView exposing (dayView)
|
||||||
|
import Views.EventDetail exposing (eventDetailView)
|
||||||
|
import Views.SpeakerDetail exposing (speakerDetailView)
|
||||||
|
import Views.ScheduleOverview exposing (scheduleOverviewView)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Date exposing (Month(..))
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, Attribute, div, input, text, li, ul, a, h4, label, i, span, hr, small, p)
|
||||||
|
import Date.Extra
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
case model.dataLoaded of
|
||||||
|
True ->
|
||||||
|
div []
|
||||||
|
[ dayPicker model
|
||||||
|
, hr [] []
|
||||||
|
, case model.route of
|
||||||
|
OverviewRoute ->
|
||||||
|
scheduleOverviewView model
|
||||||
|
|
||||||
|
OverviewFilteredRoute _ ->
|
||||||
|
scheduleOverviewView model
|
||||||
|
|
||||||
|
DayRoute dayIso ->
|
||||||
|
let
|
||||||
|
day =
|
||||||
|
model.days
|
||||||
|
|> List.filter (\x -> (Date.Extra.toFormattedString "y-MM-dd" x.date) == dayIso)
|
||||||
|
|> List.head
|
||||||
|
|> Maybe.withDefault (Day "" (Date.Extra.fromParts 1970 Jan 1 0 0 0 0) "")
|
||||||
|
in
|
||||||
|
dayView day model
|
||||||
|
|
||||||
|
EventRoute eventSlug ->
|
||||||
|
eventDetailView eventSlug model
|
||||||
|
|
||||||
|
SpeakerRoute speakerSlug ->
|
||||||
|
speakerDetailView speakerSlug model
|
||||||
|
|
||||||
|
NotFoundRoute ->
|
||||||
|
div [] [ text "Not found!" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
False ->
|
||||||
|
h4 [] [ text "Loading schedule..." ]
|
86
schedule/src/Views/DayPicker.elm
Normal file
86
schedule/src/Views/DayPicker.elm
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
module Views.DayPicker exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Models exposing (..)
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
import Views.FilterView exposing (maybeFilteredOverviewRoute)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Date exposing (Date)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, text, a, div)
|
||||||
|
import Html.Attributes exposing (class, classList, href, id)
|
||||||
|
import Date.Extra
|
||||||
|
|
||||||
|
|
||||||
|
dayPicker : Model -> Html Msg
|
||||||
|
dayPicker model =
|
||||||
|
let
|
||||||
|
activeDate =
|
||||||
|
case model.route of
|
||||||
|
DayRoute iso ->
|
||||||
|
Date.Extra.fromIsoString iso
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
isAllDaysActive =
|
||||||
|
case activeDate of
|
||||||
|
Just _ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
True
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ classList
|
||||||
|
[ ( "row", True )
|
||||||
|
, ( "sticky", True )
|
||||||
|
]
|
||||||
|
, id "daypicker"
|
||||||
|
]
|
||||||
|
[ div [ id "schedule-days", class "btn-group" ]
|
||||||
|
([ a
|
||||||
|
[ classList
|
||||||
|
[ ( "btn", True )
|
||||||
|
, ( "btn-default", not isAllDaysActive )
|
||||||
|
, ( "btn-primary", isAllDaysActive )
|
||||||
|
]
|
||||||
|
, href <| routeToString <| maybeFilteredOverviewRoute model
|
||||||
|
]
|
||||||
|
[ text "All Days"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
++ (List.map (\day -> dayButton day activeDate) model.days)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
dayButton : Day -> Maybe Date -> Html Msg
|
||||||
|
dayButton day activeDate =
|
||||||
|
let
|
||||||
|
isActive =
|
||||||
|
case activeDate of
|
||||||
|
Just activeDate ->
|
||||||
|
day.date == activeDate
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
False
|
||||||
|
in
|
||||||
|
a
|
||||||
|
[ classList
|
||||||
|
[ ( "btn", True )
|
||||||
|
, ( "btn-default", not isActive )
|
||||||
|
, ( "btn-primary", isActive )
|
||||||
|
]
|
||||||
|
, href <| routeToString <| DayRoute <| Date.Extra.toFormattedString "y-MM-dd" day.date
|
||||||
|
]
|
||||||
|
[ text day.day_name
|
||||||
|
]
|
280
schedule/src/Views/DayView.elm
Normal file
280
schedule/src/Views/DayView.elm
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
module Views.DayView exposing (dayView)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Models exposing (Model, Day, EventInstance, Route(EventRoute), FilterType(..), getSlugFromFilterType, getNameFromFilterType)
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Date exposing (Date)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, text, div, ul, li, span, i, h4, table, p, a)
|
||||||
|
import Html.Attributes exposing (classList, style, href)
|
||||||
|
import Date.Extra
|
||||||
|
import List.Extra
|
||||||
|
|
||||||
|
|
||||||
|
blockHeight : Int
|
||||||
|
blockHeight =
|
||||||
|
15
|
||||||
|
|
||||||
|
|
||||||
|
headerHeight : Int
|
||||||
|
headerHeight =
|
||||||
|
50
|
||||||
|
|
||||||
|
|
||||||
|
px : Int -> String
|
||||||
|
px value =
|
||||||
|
(toString value) ++ "px"
|
||||||
|
|
||||||
|
|
||||||
|
dayView : Day -> Model -> Html Msg
|
||||||
|
dayView day model =
|
||||||
|
let
|
||||||
|
start =
|
||||||
|
Date.Extra.add Date.Extra.Hour model.flags.schedule_midnight_offset_hours day.date
|
||||||
|
|
||||||
|
lastHour =
|
||||||
|
Date.Extra.add Date.Extra.Day 1 start
|
||||||
|
|
||||||
|
minutes =
|
||||||
|
Date.Extra.range Date.Extra.Minute 15 start lastHour
|
||||||
|
|
||||||
|
filteredEventInstances =
|
||||||
|
List.filter (\x -> Date.Extra.equalBy Date.Extra.Day x.from day.date) model.eventInstances
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ classList [ ( "row", True ) ] ]
|
||||||
|
[ gutter minutes
|
||||||
|
, locationColumns filteredEventInstances model.eventLocations model.flags.schedule_midnight_offset_hours minutes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
locationColumns : List EventInstance -> List FilterType -> Int -> List Date -> Html Msg
|
||||||
|
locationColumns eventInstances eventLocations offset minutes =
|
||||||
|
let
|
||||||
|
columnWidth =
|
||||||
|
100.0 / toFloat (List.length eventLocations)
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ style
|
||||||
|
[ ( "display", "flex" )
|
||||||
|
, ( "justify-content", "space-around" )
|
||||||
|
]
|
||||||
|
, classList
|
||||||
|
[ ( "col-sm-11", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
(List.map (\location -> locationColumn columnWidth eventInstances offset minutes location) eventLocations)
|
||||||
|
|
||||||
|
|
||||||
|
locationColumn : Float -> List EventInstance -> Int -> List Date -> FilterType -> Html Msg
|
||||||
|
locationColumn columnWidth eventInstances offset minutes location =
|
||||||
|
let
|
||||||
|
locationInstances =
|
||||||
|
List.filter (\instance -> instance.location == getSlugFromFilterType location) eventInstances
|
||||||
|
|
||||||
|
overlappingGroups =
|
||||||
|
List.Extra.groupWhile
|
||||||
|
(\instanceA instanceB ->
|
||||||
|
(Date.Extra.isBetween instanceB.from instanceB.to instanceA.from) && not (Date.Extra.equal instanceA.from instanceB.to)
|
||||||
|
)
|
||||||
|
locationInstances
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ style
|
||||||
|
[ ( "width", (toString columnWidth) ++ "%" )
|
||||||
|
]
|
||||||
|
, classList
|
||||||
|
[ ( "location-column", True ) ]
|
||||||
|
]
|
||||||
|
([ div
|
||||||
|
[ style
|
||||||
|
[ ( "height", px headerHeight )
|
||||||
|
]
|
||||||
|
, classList
|
||||||
|
[ ( "location-column-header", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[ text <| getNameFromFilterType location ]
|
||||||
|
]
|
||||||
|
++ (List.map
|
||||||
|
(\x ->
|
||||||
|
div
|
||||||
|
[ style
|
||||||
|
[ ( "backgroundColor"
|
||||||
|
, if Date.minute x == 30 || Date.minute x == 45 then
|
||||||
|
"#f8f8f8"
|
||||||
|
else
|
||||||
|
"#fff"
|
||||||
|
)
|
||||||
|
, ( "height", px blockHeight )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
minutes
|
||||||
|
)
|
||||||
|
++ (List.map (\group -> renderGroup offset group) overlappingGroups)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
renderGroup : Int -> List EventInstance -> Html Msg
|
||||||
|
renderGroup offset group =
|
||||||
|
let
|
||||||
|
sortedGroup =
|
||||||
|
List.sortWith
|
||||||
|
(\x y ->
|
||||||
|
case Date.Extra.compare x.from y.from of
|
||||||
|
z ->
|
||||||
|
z
|
||||||
|
)
|
||||||
|
group
|
||||||
|
|
||||||
|
findLefts instanceA =
|
||||||
|
( instanceA
|
||||||
|
, List.foldl
|
||||||
|
(+)
|
||||||
|
0
|
||||||
|
(List.map
|
||||||
|
(\instanceB ->
|
||||||
|
if instanceA == instanceB then
|
||||||
|
0
|
||||||
|
else if (Date.Extra.equal instanceB.from instanceA.from) && (Date.Extra.equal instanceB.to instanceA.to) then
|
||||||
|
-- Set to 0 and then fix it further down in the code
|
||||||
|
0
|
||||||
|
else if (Date.Extra.equal instanceB.from instanceA.from) && not (Date.Extra.equal instanceB.to instanceA.to) then
|
||||||
|
-- Set to 0 and then fix it further down in the code
|
||||||
|
0
|
||||||
|
else if (Date.Extra.isBetween instanceB.from instanceB.to instanceA.from) then
|
||||||
|
1
|
||||||
|
else
|
||||||
|
0
|
||||||
|
)
|
||||||
|
sortedGroup
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lefts =
|
||||||
|
List.map findLefts sortedGroup
|
||||||
|
|
||||||
|
numberInGroup =
|
||||||
|
lefts
|
||||||
|
|> List.map (\( _, left ) -> left)
|
||||||
|
|> List.maximum
|
||||||
|
|> Maybe.withDefault 1
|
||||||
|
|
||||||
|
fixedLefts =
|
||||||
|
if numberInGroup == 0 then
|
||||||
|
List.map
|
||||||
|
(\( instance, x ) ->
|
||||||
|
( instance
|
||||||
|
, lefts
|
||||||
|
|> List.Extra.elemIndex ( instance, x )
|
||||||
|
|> Maybe.withDefault 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lefts
|
||||||
|
else
|
||||||
|
lefts
|
||||||
|
|
||||||
|
fixedNumberInGroup =
|
||||||
|
fixedLefts
|
||||||
|
|> List.map (\( _, left ) -> left)
|
||||||
|
|> List.maximum
|
||||||
|
|> Maybe.withDefault 1
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ style
|
||||||
|
[ ( "display", "flex" )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
(List.map (\instance -> eventInstanceBlock offset fixedNumberInGroup instance) fixedLefts)
|
||||||
|
|
||||||
|
|
||||||
|
eventInstanceBlock : Int -> Int -> ( EventInstance, Int ) -> Html Msg
|
||||||
|
eventInstanceBlock offset numberInGroup ( eventInstance, lefts ) =
|
||||||
|
let
|
||||||
|
length =
|
||||||
|
(toFloat (Date.Extra.diff Date.Extra.Minute eventInstance.from eventInstance.to)) / 15
|
||||||
|
|
||||||
|
height =
|
||||||
|
(toString (length * toFloat blockHeight)) ++ "px"
|
||||||
|
|
||||||
|
hourInMinutes =
|
||||||
|
(Date.hour eventInstance.from) * 60
|
||||||
|
|
||||||
|
minutes =
|
||||||
|
Date.minute eventInstance.from
|
||||||
|
|
||||||
|
topOffset =
|
||||||
|
((((toFloat (hourInMinutes + minutes)) / 60)
|
||||||
|
- (toFloat offset)
|
||||||
|
)
|
||||||
|
* 4.0
|
||||||
|
* (toFloat blockHeight)
|
||||||
|
)
|
||||||
|
+ (toFloat headerHeight)
|
||||||
|
|
||||||
|
width =
|
||||||
|
100 / (toFloat (numberInGroup + 1))
|
||||||
|
in
|
||||||
|
a
|
||||||
|
[ classList
|
||||||
|
[ ( "event", True )
|
||||||
|
, ( "event-in-dayview", True )
|
||||||
|
]
|
||||||
|
, style
|
||||||
|
[ ( "height", height )
|
||||||
|
, ( "width", (toString width) ++ "%" )
|
||||||
|
, ( "top", (toString topOffset) ++ "px" )
|
||||||
|
, ( "left", (toString (toFloat (lefts) * width)) ++ "%" )
|
||||||
|
, ( "background-color", eventInstance.backgroundColor )
|
||||||
|
, ( "color", eventInstance.forgroundColor )
|
||||||
|
]
|
||||||
|
, href <| routeToString <| EventRoute eventInstance.eventSlug
|
||||||
|
]
|
||||||
|
[ p [] [ text ((Date.Extra.toFormattedString "HH:mm" eventInstance.from) ++ " " ++ eventInstance.title) ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
gutter : List Date -> Html Msg
|
||||||
|
gutter hours =
|
||||||
|
div
|
||||||
|
[ classList
|
||||||
|
[ ( "col-sm-1", True )
|
||||||
|
, ( "day-view-gutter", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
([ div [ style [ ( "height", px headerHeight ) ] ]
|
||||||
|
[ text ""
|
||||||
|
]
|
||||||
|
]
|
||||||
|
++ (List.map gutterHour hours)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
gutterHour : Date -> Html Msg
|
||||||
|
gutterHour date =
|
||||||
|
let
|
||||||
|
textToShow =
|
||||||
|
case Date.minute date of
|
||||||
|
0 ->
|
||||||
|
(Date.Extra.toFormattedString "HH:mm" date)
|
||||||
|
|
||||||
|
30 ->
|
||||||
|
(Date.Extra.toFormattedString "HH:mm" date)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
""
|
||||||
|
in
|
||||||
|
div [ style [ ( "height", px blockHeight ) ] ]
|
||||||
|
[ text textToShow
|
||||||
|
]
|
206
schedule/src/Views/EventDetail.elm
Normal file
206
schedule/src/Views/EventDetail.elm
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
module Views.EventDetail exposing (eventDetailView)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Models exposing (..)
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Date
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, text, div, ul, li, span, i, h3, h4, a, p, hr, strong)
|
||||||
|
import Html.Attributes exposing (class, classList, href)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Markdown
|
||||||
|
import Date.Extra
|
||||||
|
|
||||||
|
|
||||||
|
eventDetailView : EventSlug -> Model -> Html Msg
|
||||||
|
eventDetailView eventSlug model =
|
||||||
|
let
|
||||||
|
event =
|
||||||
|
model.events
|
||||||
|
|> List.filter (\e -> e.slug == eventSlug)
|
||||||
|
|> List.head
|
||||||
|
in
|
||||||
|
case event of
|
||||||
|
Just event ->
|
||||||
|
div [ class "row" ]
|
||||||
|
[ eventDetailContent event
|
||||||
|
, eventDetailSidebar event model
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
div [ class "row" ]
|
||||||
|
[ h4 [] [ text "Event not found." ]
|
||||||
|
, a [ href "#" ] [ text "Click here to go the schedule overview." ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
eventDetailContent : Event -> Html Msg
|
||||||
|
eventDetailContent event =
|
||||||
|
div [ class "col-sm-9" ]
|
||||||
|
[ a [ onClick BackInHistory, classList [ ( "btn", True ), ( "btn-default", True ) ] ]
|
||||||
|
[ i [ classList [ ( "fa", True ), ( "fa-chevron-left", True ) ] ] []
|
||||||
|
, text " Back"
|
||||||
|
]
|
||||||
|
, h3 [] [ text event.title ]
|
||||||
|
, div [] [ Markdown.toHtml [] event.abstract ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
getSpeakersFromSlugs : List Speaker -> List SpeakerSlug -> List Speaker -> List Speaker
|
||||||
|
getSpeakersFromSlugs speakers slugs collectedSpeakers =
|
||||||
|
case speakers of
|
||||||
|
[] ->
|
||||||
|
collectedSpeakers
|
||||||
|
|
||||||
|
speaker :: rest ->
|
||||||
|
let
|
||||||
|
foundSlug =
|
||||||
|
slugs
|
||||||
|
|> List.filter (\slug -> slug == speaker.slug)
|
||||||
|
|> List.head
|
||||||
|
|
||||||
|
foundSpeaker =
|
||||||
|
case foundSlug of
|
||||||
|
Just slug ->
|
||||||
|
[ speaker ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
newSlugs =
|
||||||
|
case foundSlug of
|
||||||
|
Just slug ->
|
||||||
|
List.filter (\x -> x /= slug) slugs
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
slugs
|
||||||
|
|
||||||
|
newCollectedSpeakers =
|
||||||
|
collectedSpeakers ++ foundSpeaker
|
||||||
|
in
|
||||||
|
case slugs of
|
||||||
|
[] ->
|
||||||
|
collectedSpeakers
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
getSpeakersFromSlugs rest newSlugs newCollectedSpeakers
|
||||||
|
|
||||||
|
|
||||||
|
eventDetailSidebar : Event -> Model -> Html Msg
|
||||||
|
eventDetailSidebar event model =
|
||||||
|
let
|
||||||
|
videoRecordingLink =
|
||||||
|
case event.videoUrl of
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Just url ->
|
||||||
|
[ a [ href url, classList [ ( "btn", True ), ( "btn-success", True ) ] ]
|
||||||
|
[ i [ classList [ ( "fa", True ), ( "fa-film", True ) ] ] []
|
||||||
|
, text " Watch recording here!"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
eventInstances =
|
||||||
|
List.filter (\instance -> instance.eventSlug == event.slug) model.eventInstances
|
||||||
|
|
||||||
|
speakers =
|
||||||
|
getSpeakersFromSlugs model.speakers event.speakerSlugs []
|
||||||
|
in
|
||||||
|
div
|
||||||
|
[ classList
|
||||||
|
[ ( "col-sm-3", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
(videoRecordingLink
|
||||||
|
++ [ speakerSidebar speakers
|
||||||
|
, eventMetaDataSidebar event
|
||||||
|
, eventInstancesSidebar eventInstances
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
eventMetaDataSidebar : Event -> Html Msg
|
||||||
|
eventMetaDataSidebar event =
|
||||||
|
let
|
||||||
|
( showVideoRecoring, videoRecording ) =
|
||||||
|
case event.videoState of
|
||||||
|
"to-be-recorded" ->
|
||||||
|
( True, "Yes" )
|
||||||
|
|
||||||
|
"not-to-be-recorded" ->
|
||||||
|
( True, "No" )
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( False, "" )
|
||||||
|
in
|
||||||
|
div []
|
||||||
|
[ h4 [] [ text "Metadata" ]
|
||||||
|
, ul []
|
||||||
|
([ li [] [ strong [] [ text "Type: " ], text event.eventType ]
|
||||||
|
]
|
||||||
|
++ (case showVideoRecoring of
|
||||||
|
True ->
|
||||||
|
[ li [] [ strong [] [ text "Recording: " ], text videoRecording ] ]
|
||||||
|
|
||||||
|
False ->
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
speakerSidebar : List Speaker -> Html Msg
|
||||||
|
speakerSidebar speakers =
|
||||||
|
div []
|
||||||
|
[ h4 []
|
||||||
|
[ text "Speakers" ]
|
||||||
|
, ul
|
||||||
|
[]
|
||||||
|
(List.map speakerDetail speakers)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
speakerDetail : Speaker -> Html Msg
|
||||||
|
speakerDetail speaker =
|
||||||
|
li []
|
||||||
|
[ a [ href <| routeToString <| SpeakerRoute speaker.slug ] [ text speaker.name ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
eventInstancesSidebar : List EventInstance -> Html Msg
|
||||||
|
eventInstancesSidebar eventInstances =
|
||||||
|
div []
|
||||||
|
[ h4 []
|
||||||
|
[ text "This event will occur at:" ]
|
||||||
|
, ul
|
||||||
|
[]
|
||||||
|
(List.map eventInstanceItem eventInstances)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
eventInstanceItem : EventInstance -> Html Msg
|
||||||
|
eventInstanceItem eventInstance =
|
||||||
|
let
|
||||||
|
toFormat =
|
||||||
|
if Date.day eventInstance.from == Date.day eventInstance.to then
|
||||||
|
"HH:mm"
|
||||||
|
else
|
||||||
|
"E HH:mm"
|
||||||
|
in
|
||||||
|
li []
|
||||||
|
[ text
|
||||||
|
((Date.Extra.toFormattedString "E HH:mm" eventInstance.from)
|
||||||
|
++ " to "
|
||||||
|
++ (Date.Extra.toFormattedString toFormat eventInstance.to)
|
||||||
|
)
|
||||||
|
]
|
367
schedule/src/Views/FilterView.elm
Normal file
367
schedule/src/Views/FilterView.elm
Normal file
|
@ -0,0 +1,367 @@
|
||||||
|
module Views.FilterView exposing (filterSidebar, applyFilters, parseFilterFromQuery, filterToQuery, maybeFilteredOverviewRoute)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Models exposing (Model, EventInstance, Filter, Day, FilterQuery, Route(OverviewRoute, OverviewFilteredRoute), FilterType(..), unpackFilterType, getSlugFromFilterType)
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
|
||||||
|
|
||||||
|
-- Core modules
|
||||||
|
|
||||||
|
import Regex
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, text, div, ul, li, span, i, h4, small, a)
|
||||||
|
import Html.Attributes exposing (class, classList, style, href)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Date.Extra exposing (Interval(..), equalBy)
|
||||||
|
|
||||||
|
|
||||||
|
applyFilters : Day -> Model -> List EventInstance
|
||||||
|
applyFilters day model =
|
||||||
|
let
|
||||||
|
slugs default filters =
|
||||||
|
List.map getSlugFromFilterType
|
||||||
|
(if List.isEmpty filters then
|
||||||
|
default
|
||||||
|
else
|
||||||
|
filters
|
||||||
|
)
|
||||||
|
|
||||||
|
types =
|
||||||
|
slugs model.eventTypes model.filter.eventTypes
|
||||||
|
|
||||||
|
locations =
|
||||||
|
slugs model.eventLocations model.filter.eventLocations
|
||||||
|
|
||||||
|
videoFilters =
|
||||||
|
slugs videoRecordingFilters model.filter.videoRecording
|
||||||
|
|
||||||
|
filteredEventInstances =
|
||||||
|
List.filter
|
||||||
|
(\eventInstance ->
|
||||||
|
(Date.Extra.equalBy Month eventInstance.from day.date)
|
||||||
|
&& (Date.Extra.equalBy Date.Extra.Day eventInstance.from day.date)
|
||||||
|
&& List.member eventInstance.location locations
|
||||||
|
&& List.member eventInstance.eventType types
|
||||||
|
&& List.member eventInstance.videoState videoFilters
|
||||||
|
)
|
||||||
|
model.eventInstances
|
||||||
|
in
|
||||||
|
filteredEventInstances
|
||||||
|
|
||||||
|
|
||||||
|
filterSidebar : Model -> Html Msg
|
||||||
|
filterSidebar model =
|
||||||
|
div
|
||||||
|
[ classList
|
||||||
|
[ ( "col-sm-3", True )
|
||||||
|
, ( "col-sm-push-9", True )
|
||||||
|
, ( "schedule-filter", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[ h4 [] [ text "Filter" ]
|
||||||
|
, div [ class "form-group" ]
|
||||||
|
[ filterView
|
||||||
|
"Type"
|
||||||
|
model.eventTypes
|
||||||
|
model.filter.eventTypes
|
||||||
|
model.eventInstances
|
||||||
|
.eventType
|
||||||
|
, filterView
|
||||||
|
"Location"
|
||||||
|
model.eventLocations
|
||||||
|
model.filter.eventLocations
|
||||||
|
model.eventInstances
|
||||||
|
.location
|
||||||
|
, filterView
|
||||||
|
"Video"
|
||||||
|
videoRecordingFilters
|
||||||
|
model.filter.videoRecording
|
||||||
|
model.eventInstances
|
||||||
|
.videoState
|
||||||
|
]
|
||||||
|
, icsButton model
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
icsButton : Model -> Html Msg
|
||||||
|
icsButton model =
|
||||||
|
let
|
||||||
|
filterString =
|
||||||
|
case filterToString model.filter of
|
||||||
|
"" ->
|
||||||
|
""
|
||||||
|
|
||||||
|
filter ->
|
||||||
|
"?" ++ filter
|
||||||
|
|
||||||
|
icsURL =
|
||||||
|
model.flags.ics_button_href ++ filterString
|
||||||
|
in
|
||||||
|
a
|
||||||
|
[ classList
|
||||||
|
[ ( "btn", True )
|
||||||
|
, ( "btn-default", True )
|
||||||
|
]
|
||||||
|
, href <| icsURL
|
||||||
|
]
|
||||||
|
[ i [ classList [ ( "fa", True ), ( "fa-calendar", True ) ] ]
|
||||||
|
[]
|
||||||
|
, text " ICS file with these filters"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
videoRecordingFilters : List FilterType
|
||||||
|
videoRecordingFilters =
|
||||||
|
[ VideoFilter "Will not be recorded" "not-to-be-recorded"
|
||||||
|
, VideoFilter "Will be recorded" "to-be-recorded"
|
||||||
|
, VideoFilter "Has recording" "has-recording"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
filterView :
|
||||||
|
String
|
||||||
|
-> List FilterType
|
||||||
|
-> List FilterType
|
||||||
|
-> List EventInstance
|
||||||
|
-> (EventInstance -> String)
|
||||||
|
-> Html Msg
|
||||||
|
filterView name possibleFilters currentFilters eventInstances slugLike =
|
||||||
|
div []
|
||||||
|
[ text (name ++ ":")
|
||||||
|
, ul []
|
||||||
|
(possibleFilters
|
||||||
|
|> List.map
|
||||||
|
(\filter ->
|
||||||
|
filterChoiceView
|
||||||
|
filter
|
||||||
|
currentFilters
|
||||||
|
eventInstances
|
||||||
|
slugLike
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
filterChoiceView :
|
||||||
|
FilterType
|
||||||
|
-> List FilterType
|
||||||
|
-> List EventInstance
|
||||||
|
-> (EventInstance -> String)
|
||||||
|
-> Html Msg
|
||||||
|
filterChoiceView filter currentFilters eventInstances slugLike =
|
||||||
|
let
|
||||||
|
active =
|
||||||
|
List.member filter currentFilters
|
||||||
|
|
||||||
|
notActive =
|
||||||
|
not active
|
||||||
|
|
||||||
|
( name, slug ) =
|
||||||
|
unpackFilterType filter
|
||||||
|
|
||||||
|
eventInstanceCount =
|
||||||
|
eventInstances
|
||||||
|
|> List.filter (\eventInstance -> slugLike eventInstance == slug)
|
||||||
|
|> List.length
|
||||||
|
|
||||||
|
buttonStyle =
|
||||||
|
case filter of
|
||||||
|
TypeFilter _ _ color lightText ->
|
||||||
|
[ style
|
||||||
|
[ ( "backgroundColor", color )
|
||||||
|
, ( "color"
|
||||||
|
, if lightText then
|
||||||
|
"#fff"
|
||||||
|
else
|
||||||
|
"#000"
|
||||||
|
)
|
||||||
|
, ( "border", "1px solid black" )
|
||||||
|
, ( "margin-bottom", "2px" )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
locationIcon =
|
||||||
|
case filter of
|
||||||
|
LocationFilter _ _ icon ->
|
||||||
|
[ i
|
||||||
|
[ classList
|
||||||
|
[ ( "fa", True )
|
||||||
|
, ( "fa-" ++ icon, True )
|
||||||
|
, ( "pull-right", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
videoIcon =
|
||||||
|
case filter of
|
||||||
|
VideoFilter _ slug ->
|
||||||
|
let
|
||||||
|
icon =
|
||||||
|
case slug of
|
||||||
|
"has-recording" ->
|
||||||
|
"film"
|
||||||
|
|
||||||
|
"to-be-recorded" ->
|
||||||
|
"video-camera"
|
||||||
|
|
||||||
|
"not-to-be-recorded" ->
|
||||||
|
"ban"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
""
|
||||||
|
in
|
||||||
|
[ i
|
||||||
|
[ classList
|
||||||
|
[ ( "fa", True )
|
||||||
|
, ( "fa-" ++ icon, True )
|
||||||
|
, ( "pull-right", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
li
|
||||||
|
[]
|
||||||
|
[ div
|
||||||
|
([ classList
|
||||||
|
[ ( "btn", True )
|
||||||
|
, ( "btn-default", True )
|
||||||
|
, ( "filter-choice-active", active )
|
||||||
|
]
|
||||||
|
, onClick (ToggleFilter filter)
|
||||||
|
]
|
||||||
|
++ buttonStyle
|
||||||
|
)
|
||||||
|
[ span []
|
||||||
|
([ span [ classList [ ( "pull-left", True ) ] ]
|
||||||
|
[ i
|
||||||
|
[ classList
|
||||||
|
[ ( "fa", True )
|
||||||
|
, ( "fa-minus", active )
|
||||||
|
, ( "fa-plus", notActive )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, text (" " ++ name)
|
||||||
|
, small [] [ text <| " (" ++ (toString eventInstanceCount) ++ ")" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
++ locationIcon
|
||||||
|
++ videoIcon
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
findFilter : List FilterType -> String -> Maybe FilterType
|
||||||
|
findFilter modelItems filterSlug =
|
||||||
|
List.head
|
||||||
|
(List.filter
|
||||||
|
(\x ->
|
||||||
|
let
|
||||||
|
( _, slug ) =
|
||||||
|
unpackFilterType x
|
||||||
|
in
|
||||||
|
slug == filterSlug
|
||||||
|
)
|
||||||
|
modelItems
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
getFilter : String -> List FilterType -> String -> List FilterType
|
||||||
|
getFilter filterType modelItems query =
|
||||||
|
let
|
||||||
|
filterMatch =
|
||||||
|
query
|
||||||
|
|> Regex.find (Regex.AtMost 1) (Regex.regex (filterType ++ "=([\\w,_-]+)&*"))
|
||||||
|
|> List.concatMap .submatches
|
||||||
|
|> List.head
|
||||||
|
|> Maybe.withDefault Nothing
|
||||||
|
|> Maybe.withDefault ""
|
||||||
|
|
||||||
|
filterSlugs =
|
||||||
|
String.split "," filterMatch
|
||||||
|
in
|
||||||
|
List.filterMap (\x -> findFilter modelItems x) filterSlugs
|
||||||
|
|
||||||
|
|
||||||
|
parseFilterFromQuery : FilterQuery -> Model -> Filter
|
||||||
|
parseFilterFromQuery query model =
|
||||||
|
let
|
||||||
|
types =
|
||||||
|
getFilter "type" model.eventTypes query
|
||||||
|
|
||||||
|
locations =
|
||||||
|
getFilter "location" model.eventLocations query
|
||||||
|
|
||||||
|
videoFilters =
|
||||||
|
getFilter "video" videoRecordingFilters query
|
||||||
|
in
|
||||||
|
{ eventTypes = types
|
||||||
|
, eventLocations = locations
|
||||||
|
, videoRecording = videoFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
filterToString : Filter -> String
|
||||||
|
filterToString filter =
|
||||||
|
let
|
||||||
|
typePart =
|
||||||
|
case String.join "," (List.map getSlugFromFilterType filter.eventTypes) of
|
||||||
|
"" ->
|
||||||
|
""
|
||||||
|
|
||||||
|
types ->
|
||||||
|
"type=" ++ types
|
||||||
|
|
||||||
|
locationPart =
|
||||||
|
case String.join "," (List.map getSlugFromFilterType filter.eventLocations) of
|
||||||
|
"" ->
|
||||||
|
""
|
||||||
|
|
||||||
|
locations ->
|
||||||
|
"location=" ++ locations
|
||||||
|
|
||||||
|
videoPart =
|
||||||
|
case String.join "," (List.map getSlugFromFilterType filter.videoRecording) of
|
||||||
|
"" ->
|
||||||
|
""
|
||||||
|
|
||||||
|
video ->
|
||||||
|
"video=" ++ video
|
||||||
|
in
|
||||||
|
String.join "&" (List.filter (\x -> x /= "") [ typePart, locationPart, videoPart ])
|
||||||
|
|
||||||
|
|
||||||
|
filterToQuery : Filter -> FilterQuery
|
||||||
|
filterToQuery filter =
|
||||||
|
let
|
||||||
|
result =
|
||||||
|
filterToString filter
|
||||||
|
in
|
||||||
|
routeToString <| OverviewFilteredRoute result
|
||||||
|
|
||||||
|
|
||||||
|
maybeFilteredOverviewRoute : Model -> Route
|
||||||
|
maybeFilteredOverviewRoute model =
|
||||||
|
case filterToString model.filter of
|
||||||
|
"" ->
|
||||||
|
OverviewRoute
|
||||||
|
|
||||||
|
query ->
|
||||||
|
OverviewFilteredRoute query
|
104
schedule/src/Views/ScheduleOverview.elm
Normal file
104
schedule/src/Views/ScheduleOverview.elm
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
module Views.ScheduleOverview exposing (scheduleOverviewView)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg(..))
|
||||||
|
import Models exposing (Model, Day, EventInstance, Filter, Route(EventRoute))
|
||||||
|
import Views.FilterView exposing (filterSidebar, applyFilters, parseFilterFromQuery)
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, text, div, ul, li, span, i, h4, p, small, a)
|
||||||
|
import Html.Lazy exposing (lazy, lazy2)
|
||||||
|
import Html.Attributes exposing (class, classList, href, style)
|
||||||
|
import Date.Extra
|
||||||
|
|
||||||
|
|
||||||
|
scheduleOverviewView : Model -> Html Msg
|
||||||
|
scheduleOverviewView model =
|
||||||
|
div [ class "row" ]
|
||||||
|
[ filterSidebar model
|
||||||
|
, div
|
||||||
|
[ classList
|
||||||
|
[ ( "col-sm-9", True )
|
||||||
|
, ( "col-sm-pull-3", True )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
(List.map (\day -> lazy2 dayRowView day model) model.days)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
dayRowView : Day -> Model -> Html Msg
|
||||||
|
dayRowView day model =
|
||||||
|
let
|
||||||
|
filteredEventInstances =
|
||||||
|
applyFilters day model
|
||||||
|
in
|
||||||
|
div []
|
||||||
|
[ h4 []
|
||||||
|
[ text day.repr ]
|
||||||
|
, div [ class "schedule-day-row" ]
|
||||||
|
(List.map (lazy dayEventInstanceView) filteredEventInstances)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
dayEventInstanceView : EventInstance -> Html Msg
|
||||||
|
dayEventInstanceView eventInstance =
|
||||||
|
a
|
||||||
|
[ classList
|
||||||
|
[ ( "event", True )
|
||||||
|
, ( "event-in-overview", True )
|
||||||
|
]
|
||||||
|
, href <| routeToString <| EventRoute eventInstance.eventSlug
|
||||||
|
, style
|
||||||
|
[ ( "background-color", eventInstance.backgroundColor )
|
||||||
|
, ( "color", eventInstance.forgroundColor )
|
||||||
|
]
|
||||||
|
]
|
||||||
|
([ small []
|
||||||
|
[ text
|
||||||
|
((Date.Extra.toFormattedString "HH:mm" eventInstance.from)
|
||||||
|
++ " - "
|
||||||
|
++ (Date.Extra.toFormattedString "HH:mm" eventInstance.to)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
++ (dayEventInstanceIcons eventInstance)
|
||||||
|
++ [ p
|
||||||
|
[]
|
||||||
|
[ text eventInstance.title ]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
dayEventInstanceIcons : EventInstance -> List (Html Msg)
|
||||||
|
dayEventInstanceIcons eventInstance =
|
||||||
|
let
|
||||||
|
videoIcon =
|
||||||
|
case eventInstance.videoState of
|
||||||
|
"has-recording" ->
|
||||||
|
[ i
|
||||||
|
[ classList [ ( "fa", True ), ( "fa-film", True ), ( "pull-right", True ) ] ]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
|
||||||
|
"to-be-recorded" ->
|
||||||
|
[ i
|
||||||
|
[ classList [ ( "fa", True ), ( "fa-video-camera", True ), ( "pull-right", True ) ] ]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
|
||||||
|
"not-to-be-recorded" ->
|
||||||
|
[ i
|
||||||
|
[ classList [ ( "fa", True ), ( "fa-ban", True ), ( "pull-right", True ) ] ]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
[ i [ classList [ ( "fa", True ), ( "fa-" ++ eventInstance.locationIcon, True ), ( "pull-right", True ) ] ] []
|
||||||
|
]
|
||||||
|
++ videoIcon
|
82
schedule/src/Views/SpeakerDetail.elm
Normal file
82
schedule/src/Views/SpeakerDetail.elm
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
module Views.SpeakerDetail exposing (..)
|
||||||
|
|
||||||
|
-- Local modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg(BackInHistory))
|
||||||
|
import Models exposing (Model, SpeakerSlug, Speaker, Route(EventRoute), Event)
|
||||||
|
import Routing exposing (routeToString)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import Html exposing (Html, div, text, a, i, h3, img, ul, li, p)
|
||||||
|
import Html.Attributes exposing (classList, src, href)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Markdown
|
||||||
|
|
||||||
|
|
||||||
|
speakerDetailView : SpeakerSlug -> Model -> Html Msg
|
||||||
|
speakerDetailView speakerSlug model =
|
||||||
|
let
|
||||||
|
speaker =
|
||||||
|
model.speakers
|
||||||
|
|> List.filter (\speaker -> speaker.slug == speakerSlug)
|
||||||
|
|> List.head
|
||||||
|
|
||||||
|
image =
|
||||||
|
case speaker of
|
||||||
|
Just speaker ->
|
||||||
|
case speaker.smallPictureUrl of
|
||||||
|
Just smallPictureUrl ->
|
||||||
|
[ img [ src smallPictureUrl ] [] ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
case speaker of
|
||||||
|
Just speaker ->
|
||||||
|
div []
|
||||||
|
([ a [ onClick BackInHistory, classList [ ( "btn", True ), ( "btn-default", True ) ] ]
|
||||||
|
[ i [ classList [ ( "fa", True ), ( "fa-chevron-left", True ) ] ] []
|
||||||
|
, text " Back"
|
||||||
|
]
|
||||||
|
, h3 [] [ text speaker.name ]
|
||||||
|
, div [] [ Markdown.toHtml [] speaker.biography ]
|
||||||
|
, speakerEvents speaker model
|
||||||
|
]
|
||||||
|
++ image
|
||||||
|
)
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
div [] [ text "Unknown speaker..." ]
|
||||||
|
|
||||||
|
|
||||||
|
speakerEvents : Speaker -> Model -> Html Msg
|
||||||
|
speakerEvents speaker model =
|
||||||
|
let
|
||||||
|
events =
|
||||||
|
model.events
|
||||||
|
|> List.filter (\event -> List.member speaker.slug event.speakerSlugs)
|
||||||
|
in
|
||||||
|
case events of
|
||||||
|
[] ->
|
||||||
|
p [] [ text "This speaker has no events!" ]
|
||||||
|
|
||||||
|
events ->
|
||||||
|
div []
|
||||||
|
[ h3 [] [ text "Events:" ]
|
||||||
|
, ul []
|
||||||
|
(List.map
|
||||||
|
eventItem
|
||||||
|
events
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
eventItem : Event -> Html Msg
|
||||||
|
eventItem event =
|
||||||
|
li []
|
||||||
|
[ a [ href <| routeToString <| EventRoute event.slug ] [ text event.title ] ]
|
23
schedule/src/WebSocketCalls.elm
Normal file
23
schedule/src/WebSocketCalls.elm
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
module WebSocketCalls exposing (sendInitMessage)
|
||||||
|
|
||||||
|
-- Internal modules
|
||||||
|
|
||||||
|
import Messages exposing (Msg)
|
||||||
|
|
||||||
|
|
||||||
|
-- External modules
|
||||||
|
|
||||||
|
import WebSocket
|
||||||
|
import Json.Encode
|
||||||
|
|
||||||
|
|
||||||
|
sendInitMessage : String -> String -> Cmd Msg
|
||||||
|
sendInitMessage camp_slug scheduleServer =
|
||||||
|
WebSocket.send scheduleServer
|
||||||
|
(Json.Encode.encode 0
|
||||||
|
(Json.Encode.object
|
||||||
|
[ ( "action", Json.Encode.string "init" )
|
||||||
|
, ( "camp_slug", Json.Encode.string camp_slug )
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
|
@ -68,7 +68,7 @@ BANKACCOUNT_ACCOUNT='{{ accountno }}'
|
||||||
TICKET_CATEGORY_NAME='Tickets'
|
TICKET_CATEGORY_NAME='Tickets'
|
||||||
|
|
||||||
# schedule settings
|
# schedule settings
|
||||||
SCHEDULE_MIDNIGHT_OFFSET_HOURS=6
|
SCHEDULE_MIDNIGHT_OFFSET_HOURS=9
|
||||||
SCHEDULE_TIMESLOT_LENGTH_MINUTES=30
|
SCHEDULE_TIMESLOT_LENGTH_MINUTES=30
|
||||||
SCHEDULE_EVENT_NOTIFICATION_MINUTES=10
|
SCHEDULE_EVENT_NOTIFICATION_MINUTES=10
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||||
'ircbot',
|
'ircbot',
|
||||||
'teams',
|
'teams',
|
||||||
'people',
|
'people',
|
||||||
|
'tickets',
|
||||||
|
|
||||||
'allauth',
|
'allauth',
|
||||||
'allauth.account',
|
'allauth.account',
|
||||||
|
|
|
@ -127,16 +127,16 @@ urlpatterns = [
|
||||||
|
|
||||||
url(
|
url(
|
||||||
r'^program/', include([
|
r'^program/', include([
|
||||||
url(
|
|
||||||
r'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/$',
|
|
||||||
ScheduleView.as_view(),
|
|
||||||
name='schedule_day'
|
|
||||||
),
|
|
||||||
url(
|
url(
|
||||||
r'^$',
|
r'^$',
|
||||||
ScheduleView.as_view(),
|
ScheduleView.as_view(),
|
||||||
name='schedule_index'
|
name='schedule_index'
|
||||||
),
|
),
|
||||||
|
url(
|
||||||
|
r'^noscript/$',
|
||||||
|
NoScriptScheduleView.as_view(),
|
||||||
|
name='noscript_schedule_index'
|
||||||
|
),
|
||||||
url(
|
url(
|
||||||
r'^ics/', ICSView.as_view(), name="ics_view"
|
r'^ics/', ICSView.as_view(), name="ics_view"
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from channels.generic.websockets import JsonWebsocketConsumer
|
from channels.generic.websockets import JsonWebsocketConsumer
|
||||||
|
|
||||||
from camps.models import Camp
|
from camps.models import Camp
|
||||||
from .models import EventInstance, Favorite
|
from .models import Event, EventInstance, Favorite, EventLocation, EventType, Speaker
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConsumer(JsonWebsocketConsumer):
|
class ScheduleConsumer(JsonWebsocketConsumer):
|
||||||
|
@ -10,34 +10,52 @@ class ScheduleConsumer(JsonWebsocketConsumer):
|
||||||
def connection_groups(self, **kwargs):
|
def connection_groups(self, **kwargs):
|
||||||
return ['schedule_users']
|
return ['schedule_users']
|
||||||
|
|
||||||
def connect(self, message, **kwargs):
|
|
||||||
camp_slug = message.http_session['campslug']
|
|
||||||
try:
|
|
||||||
camp = Camp.objects.get(slug=camp_slug)
|
|
||||||
days = list(map(
|
|
||||||
lambda day:
|
|
||||||
{ 'repr': day.lower.strftime('%A %Y-%m-%d')
|
|
||||||
, 'iso': day.lower.strftime('%Y-%m-%d')
|
|
||||||
, 'day_name': day.lower.strftime('%A')
|
|
||||||
},
|
|
||||||
camp.get_days('camp')
|
|
||||||
))
|
|
||||||
event_instances_query_set = EventInstance.objects.filter(event__camp=camp)
|
|
||||||
event_instances = list(map(lambda x: x.to_json(user=message.user), event_instances_query_set))
|
|
||||||
self.send({
|
|
||||||
"accept": True,
|
|
||||||
"event_instances": event_instances,
|
|
||||||
"days": days,
|
|
||||||
"action": "init"
|
|
||||||
})
|
|
||||||
except Camp.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def raw_receive(self, message, **kwargs):
|
def raw_receive(self, message, **kwargs):
|
||||||
content = self.decode_json(message['text'])
|
content = self.decode_json(message['text'])
|
||||||
action = content.get('action')
|
action = content.get('action')
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
|
if action == 'init':
|
||||||
|
camp_slug = content.get('camp_slug')
|
||||||
|
try:
|
||||||
|
camp = Camp.objects.get(slug=camp_slug)
|
||||||
|
days = list(map(
|
||||||
|
lambda day:
|
||||||
|
{
|
||||||
|
'repr': day.lower.strftime('%A %Y-%m-%d'),
|
||||||
|
'iso': day.lower.strftime('%Y-%m-%d'),
|
||||||
|
'day_name': day.lower.strftime('%A'),
|
||||||
|
},
|
||||||
|
camp.get_days('camp')
|
||||||
|
))
|
||||||
|
|
||||||
|
events_query_set = Event.objects.filter(camp=camp)
|
||||||
|
events = list([x.serialize() for x in events_query_set])
|
||||||
|
|
||||||
|
event_instances_query_set = EventInstance.objects.filter(event__camp=camp)
|
||||||
|
event_instances = list([x.serialize(user=message.user) for x in event_instances_query_set])
|
||||||
|
|
||||||
|
event_locations_query_set = EventLocation.objects.filter(camp=camp)
|
||||||
|
event_locations = list([x.serialize() for x in event_locations_query_set])
|
||||||
|
|
||||||
|
event_types_query_set = EventType.objects.filter()
|
||||||
|
event_types = list([x.serialize() for x in event_types_query_set])
|
||||||
|
|
||||||
|
speakers_query_set = Speaker.objects.filter(camp=camp)
|
||||||
|
speakers = list([x.serialize() for x in speakers_query_set])
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"action": "init",
|
||||||
|
"events": events,
|
||||||
|
"event_instances": event_instances,
|
||||||
|
"event_locations": event_locations,
|
||||||
|
"event_types": event_types,
|
||||||
|
"speakers": speakers,
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
except Camp.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
if action == 'favorite':
|
if action == 'favorite':
|
||||||
event_instance_id = content.get('event_instance_id')
|
event_instance_id = content.get('event_instance_id')
|
||||||
event_instance = EventInstance.objects.get(id=event_instance_id)
|
event_instance = EventInstance.objects.get(id=event_instance_id)
|
||||||
|
@ -52,7 +70,8 @@ class ScheduleConsumer(JsonWebsocketConsumer):
|
||||||
favorite = Favorite.objects.get(event_instance=event_instance, user=message.user)
|
favorite = Favorite.objects.get(event_instance=event_instance, user=message.user)
|
||||||
favorite.delete()
|
favorite.delete()
|
||||||
|
|
||||||
self.send(data)
|
if data:
|
||||||
|
self.send(data)
|
||||||
|
|
||||||
def disconnect(self, message, **kwargs):
|
def disconnect(self, message, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -14,6 +14,8 @@ from django.dispatch import receiver
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.urlresolvers import reverse_lazy
|
from django.core.urlresolvers import reverse_lazy
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.urlresolvers import reverse_lazy, reverse
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
@ -311,6 +313,13 @@ class EventLocation(CampRelatedModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (('camp', 'slug'), ('camp', 'name'))
|
unique_together = (('camp', 'slug'), ('camp', 'name'))
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"slug": self.slug,
|
||||||
|
"icon": self.icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class EventType(CreatedUpdatedModel):
|
class EventType(CreatedUpdatedModel):
|
||||||
""" Every event needs to have a type. """
|
""" Every event needs to have a type. """
|
||||||
|
@ -350,6 +359,14 @@ class EventType(CreatedUpdatedModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"slug": self.slug,
|
||||||
|
"color": self.color,
|
||||||
|
"light_text": self.light_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Event(CampRelatedModel):
|
class Event(CampRelatedModel):
|
||||||
""" Something that is on the program one or more times. """
|
""" Something that is on the program one or more times. """
|
||||||
|
@ -413,6 +430,30 @@ class Event(CampRelatedModel):
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse_lazy('event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
|
return reverse_lazy('event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
data = {
|
||||||
|
'title': self.title,
|
||||||
|
'slug': self.slug,
|
||||||
|
'abstract': self.abstract,
|
||||||
|
'speaker_slugs': [
|
||||||
|
speaker.slug
|
||||||
|
for speaker in self.speakers.all()
|
||||||
|
],
|
||||||
|
'event_type': self.event_type.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.video_url:
|
||||||
|
video_state = 'has-recording'
|
||||||
|
data['video_url'] = self.video_url
|
||||||
|
elif self.video_recording:
|
||||||
|
video_state = 'to-be-recorded'
|
||||||
|
elif not self.video_recording:
|
||||||
|
video_state = 'not-to-be-recorded'
|
||||||
|
|
||||||
|
data['video_state'] = video_state
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class EventInstance(CampRelatedModel):
|
class EventInstance(CampRelatedModel):
|
||||||
""" An instance of an event """
|
""" An instance of an event """
|
||||||
|
@ -475,35 +516,32 @@ class EventInstance(CampRelatedModel):
|
||||||
ievent['location'] = icalendar.vText(self.location.name)
|
ievent['location'] = icalendar.vText(self.location.name)
|
||||||
return ievent
|
return ievent
|
||||||
|
|
||||||
def to_json(self, user=None):
|
def serialize(self, user=None):
|
||||||
parser = CommonMark.Parser()
|
|
||||||
renderer = CommonMark.HtmlRenderer()
|
|
||||||
ast = parser.parse(self.event.abstract)
|
|
||||||
abstract = renderer.render(ast)
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'title': self.event.title,
|
'title': self.event.title,
|
||||||
|
'slug': self.event.slug + '-' + str(self.id),
|
||||||
'event_slug': self.event.slug,
|
'event_slug': self.event.slug,
|
||||||
'abstract': abstract,
|
|
||||||
'from': self.when.lower.astimezone().isoformat(),
|
'from': self.when.lower.astimezone().isoformat(),
|
||||||
'to': self.when.upper.astimezone().isoformat(),
|
'to': self.when.upper.astimezone().isoformat(),
|
||||||
'url': str(self.event.get_absolute_url()),
|
'url': str(self.event.get_absolute_url()),
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'speakers': [
|
|
||||||
{'name': speaker.name, 'url': str(speaker.get_absolute_url())}
|
|
||||||
for speaker in self.event.speakers.all()
|
|
||||||
],
|
|
||||||
'bg-color': self.event.event_type.color,
|
'bg-color': self.event.event_type.color,
|
||||||
'fg-color': '#fff' if self.event.event_type.light_text else '#000',
|
'fg-color': '#fff' if self.event.event_type.light_text else '#000',
|
||||||
'event_type': self.event.event_type.slug,
|
'event_type': self.event.event_type.slug,
|
||||||
'location': self.location.slug,
|
'location': self.location.slug,
|
||||||
'location_icon': self.location.icon,
|
'location_icon': self.location.icon,
|
||||||
'timeslots': self.timeslots,
|
'timeslots': self.timeslots,
|
||||||
'video_recording': self.event.video_recording,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.event.video_url:
|
if self.event.video_url:
|
||||||
|
video_state = 'has-recording'
|
||||||
data['video_url'] = self.event.video_url
|
data['video_url'] = self.event.video_url
|
||||||
|
elif self.event.video_recording:
|
||||||
|
video_state = 'to-be-recorded'
|
||||||
|
elif not self.event.video_recording:
|
||||||
|
video_state = 'not-to-be-recorded'
|
||||||
|
|
||||||
|
data['video_state'] = video_state
|
||||||
|
|
||||||
if user and user.is_authenticated:
|
if user and user.is_authenticated:
|
||||||
is_favorited = user.favorites.filter(event_instance=self).exists()
|
is_favorited = user.favorites.filter(event_instance=self).exists()
|
||||||
|
@ -589,6 +627,29 @@ class Speaker(CampRelatedModel):
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse_lazy('speaker_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
|
return reverse_lazy('speaker_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug})
|
||||||
|
|
||||||
|
def get_picture_url(self, size):
|
||||||
|
return reverse('speaker_picture', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug, 'picture': size})
|
||||||
|
|
||||||
|
def get_small_picture_url(self):
|
||||||
|
return self.get_picture_url('thumbnail')
|
||||||
|
|
||||||
|
def get_large_picture_url(self):
|
||||||
|
return self.get_picture_url('large')
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
data = {
|
||||||
|
'name': self.name,
|
||||||
|
'slug': self.slug,
|
||||||
|
'biography': self.biography,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.picture_small and self.picture_large:
|
||||||
|
data['large_picture_url'] = self.get_large_picture_url()
|
||||||
|
data['small_picture_url'] = self.get_small_picture_url()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class Favorite(models.Model):
|
class Favorite(models.Model):
|
||||||
user = models.ForeignKey('auth.User', related_name='favorites')
|
user = models.ForeignKey('auth.User', related_name='favorites')
|
||||||
|
|
16758
src/program/static/js/elm_based_schedule.js
Normal file
16758
src/program/static/js/elm_based_schedule.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,552 +0,0 @@
|
||||||
const webSocketBridge = new channels.WebSocketBridge();
|
|
||||||
var modals = {};
|
|
||||||
var EVENT_INSTANCES = [], DAYS = [], CONFIG = {};
|
|
||||||
|
|
||||||
function toggleFavoriteButton(button) {
|
|
||||||
if(button.getAttribute('data-state') == 'true') {
|
|
||||||
favorite_button.classList.remove('btn-success');
|
|
||||||
favorite_button.classList.add('btn-danger');
|
|
||||||
favorite_button.innerHTML = '<i class="fa fa-minus"></i> Remove favorite';
|
|
||||||
|
|
||||||
favorite_button.onclick = function(e) {
|
|
||||||
button.setAttribute('data-state', 'false')
|
|
||||||
webSocketBridge.send({action: 'unfavorite', event_instance_id: event_instance_id});
|
|
||||||
toggleFavoriteButton(button)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
favorite_button.classList.remove('btn-danger');
|
|
||||||
favorite_button.classList.add('btn-success');
|
|
||||||
favorite_button.innerHTML = '<i class="fa fa-star"></i> Favorite';
|
|
||||||
|
|
||||||
favorite_button.onclick = function(e) {
|
|
||||||
button.setAttribute('data-state', 'true')
|
|
||||||
webSocketBridge.send({action: 'favorite', event_instance_id: event_instance_id});
|
|
||||||
toggleFavoriteButton(button)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup_websocket() {
|
|
||||||
webSocketBridge.connect('/schedule/');
|
|
||||||
webSocketBridge.listen(function(payload, stream) {
|
|
||||||
if(payload['action'] == 'init') {
|
|
||||||
EVENT_INSTANCES = payload['event_instances'];
|
|
||||||
DAYS = payload['days'];
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(config) {
|
|
||||||
CONFIG = config;
|
|
||||||
setup_websocket();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGetParameter(parameterName) {
|
|
||||||
var result = null,
|
|
||||||
tmp = [];
|
|
||||||
location.search
|
|
||||||
.substr(1)
|
|
||||||
.split("&")
|
|
||||||
.forEach(function (item) {
|
|
||||||
tmp = item.split("=");
|
|
||||||
if (tmp[0] === parameterName) {
|
|
||||||
result = decodeURIComponent(tmp[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function get_parameters() {
|
|
||||||
|
|
||||||
var day_parameter = findGetParameter('day');
|
|
||||||
var filter_day = day_parameter != null ? day_parameter.split(',') : [];
|
|
||||||
var type_parameter = findGetParameter('type');
|
|
||||||
var filter_types = type_parameter != null ? type_parameter.split(',') : [];
|
|
||||||
var location_parameter = findGetParameter('location')
|
|
||||||
var filter_locations = location_parameter != null ? location_parameter.split(',') : [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
'day': filter_day[0],
|
|
||||||
'types': filter_types,
|
|
||||||
'locations': filter_locations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
parameters = get_parameters();
|
|
||||||
toggleFilterBoxes(parameters['types'], parameters['locations']);
|
|
||||||
render_day_menu(parameters['day']);
|
|
||||||
setICSButtonHref(location.search);
|
|
||||||
|
|
||||||
if(parameters['day'] != null) {
|
|
||||||
render_day(parameters['types'], parameters['locations'], parameters['day']);
|
|
||||||
} else {
|
|
||||||
render_schedule(parameters['types'], parameters['locations']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_day_menu(active_iso) {
|
|
||||||
var container = document.getElementById('schedule-days');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
var mobile_container = document.getElementById('schedule-days-mobile');
|
|
||||||
mobile_container.innerHTML = '';
|
|
||||||
|
|
||||||
function set_btn_type(classList, primary) {
|
|
||||||
if(primary == true) {
|
|
||||||
classList.add('btn-primary');
|
|
||||||
} else {
|
|
||||||
classList.add('btn-default');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayEvent(e) {
|
|
||||||
setHistoryState({
|
|
||||||
'day': this.dataset.iso
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
var all_days = document.createElement('a');
|
|
||||||
all_days.classList.add('btn');
|
|
||||||
set_btn_type(all_days.classList, active_iso == null);
|
|
||||||
all_days.innerHTML = 'All days';
|
|
||||||
all_days.dataset.iso = 'all-days';
|
|
||||||
all_days.addEventListener('click', dayEvent);
|
|
||||||
container.appendChild(all_days);
|
|
||||||
|
|
||||||
all_days_mobile = all_days.cloneNode(true);
|
|
||||||
all_days_mobile.addEventListener('click', dayEvent);
|
|
||||||
mobile_container.appendChild(all_days_mobile);
|
|
||||||
|
|
||||||
for(var day_id in DAYS) {
|
|
||||||
var day_link = document.createElement('a');
|
|
||||||
day_link.classList.add('btn');
|
|
||||||
set_btn_type(day_link.classList, DAYS[day_id]['iso'] == active_iso);
|
|
||||||
day_link.dataset.iso = DAYS[day_id]['iso'];
|
|
||||||
day_link.innerHTML = DAYS[day_id]['day_name'];
|
|
||||||
|
|
||||||
day_link.addEventListener('click', dayEvent);
|
|
||||||
container.appendChild(day_link);
|
|
||||||
|
|
||||||
day_link_mobile = day_link.cloneNode(true);
|
|
||||||
day_link_mobile.addEventListener('click', dayEvent);
|
|
||||||
mobile_container.appendChild(day_link_mobile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_day(types, locations, day) {
|
|
||||||
|
|
||||||
function hoursTohhmm(hours){
|
|
||||||
var hour = Math.floor(Math.abs(hours));
|
|
||||||
var minutes = Math.floor((Math.abs(hours) * 60) % 60);
|
|
||||||
if(hour > 24) {
|
|
||||||
hour = hour - 24;
|
|
||||||
}
|
|
||||||
return (hour < 10 ? "0" : "") + hour + ":" + (minutes < 10 ? "0" : "") + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
var event_instances = get_instances(types, locations, day);
|
|
||||||
var schedule_container = document.getElementById('schedule-container');
|
|
||||||
schedule_container.innerHTML = '';
|
|
||||||
|
|
||||||
var day_table = document.createElement('table');
|
|
||||||
schedule_container.appendChild(day_table);
|
|
||||||
day_table.classList.add('table');
|
|
||||||
day_table.classList.add('day-table');
|
|
||||||
day_table_body = document.createElement('tbody');
|
|
||||||
day_table.appendChild(day_table_body);
|
|
||||||
|
|
||||||
var array_length = (24*60)/CONFIG['schedule_timeslot_length_minutes'];
|
|
||||||
var timeslots_ = Array(array_length);
|
|
||||||
var timeslots = [];
|
|
||||||
for(var i=0; i<timeslots_.length; i++) {
|
|
||||||
timeslots.push(
|
|
||||||
{ 'offset': i * CONFIG['schedule_timeslot_length_minutes']
|
|
||||||
, 'minutes_since_midnight': (CONFIG['schedule_midnight_offset_hours'] * 60) + (i * CONFIG['schedule_timeslot_length_minutes'])
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeslot_trs = {};
|
|
||||||
for(var timeslots_index in timeslots) {
|
|
||||||
var timeslot_tr = document.createElement('tr');
|
|
||||||
day_table_body.appendChild(timeslot_tr);
|
|
||||||
var timeslot_td = document.createElement('td');
|
|
||||||
timeslot_tr.appendChild(timeslot_td);
|
|
||||||
|
|
||||||
var minutes_since_midnight = timeslots[timeslots_index]['minutes_since_midnight'];
|
|
||||||
if(minutes_since_midnight / 60 % 1 == 0) {
|
|
||||||
timeslot_td.innerHTML = hoursTohhmm(minutes_since_midnight / 60);
|
|
||||||
}
|
|
||||||
timeslot_trs[hoursTohhmm(minutes_since_midnight / 60)] = timeslot_tr;
|
|
||||||
}
|
|
||||||
for(var event_instances_index in event_instances) {
|
|
||||||
var event_instance = event_instances[event_instances_index];
|
|
||||||
var event_instance_td = document.createElement('td');
|
|
||||||
event_instance_td.innerHTML = event_instance['title'];
|
|
||||||
event_instance_td.setAttribute('rowspan', event_instance['timeslots']);
|
|
||||||
event_instance_td.classList.add('event-td');
|
|
||||||
event_instance_td.setAttribute(
|
|
||||||
'style',
|
|
||||||
'background-color: ' + event_instance['bg-color'] +
|
|
||||||
'; color: ' + event_instance['fg-color']);
|
|
||||||
event_instance_td.onclick = openModal
|
|
||||||
event_instance_td.dataset.eventInstanceId = event_instance['id'];
|
|
||||||
|
|
||||||
var timeslot_tr = timeslot_trs[event_instance.from.slice(11, 16)];
|
|
||||||
|
|
||||||
timeslot_tr.appendChild(event_instance_td);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_schedule(types, locations) {
|
|
||||||
var event_instances = get_instances(types, locations);
|
|
||||||
var schedule_container = document.getElementById('schedule-container');
|
|
||||||
schedule_container.innerHTML = "";
|
|
||||||
|
|
||||||
var cloned_days = DAYS.slice(0);
|
|
||||||
|
|
||||||
var rendered_days = cloned_days.map(function(day) {
|
|
||||||
day_event_instances = event_instances.slice(0).filter(
|
|
||||||
function(event_instance) {
|
|
||||||
var event_day = event_instance['from'].slice(0, 10);
|
|
||||||
return event_day == day['iso'];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return render_schedule_day(day, day_event_instances);
|
|
||||||
});
|
|
||||||
|
|
||||||
for(day_id in rendered_days) {
|
|
||||||
schedule_container.appendChild(rendered_days[day_id]['label']);
|
|
||||||
schedule_container.appendChild(rendered_days[day_id]['element']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_schedule_day(day, event_instances) {
|
|
||||||
var element = document.createElement('div');
|
|
||||||
element.classList.add('schedule-day-row');
|
|
||||||
var day_label = document.createElement('h4');
|
|
||||||
day_label.innerHTML = day['repr'];
|
|
||||||
element.appendChild(day_label);
|
|
||||||
for(event_instance_id in event_instances) {
|
|
||||||
var event_instance = event_instances[event_instance_id];
|
|
||||||
var rendered_event_instance = render_event_instance(event_instance);
|
|
||||||
element.appendChild(rendered_event_instance);
|
|
||||||
}
|
|
||||||
return {"label": day_label, "element": element};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_event_instance(event_instance) {
|
|
||||||
var element = document.createElement('a');
|
|
||||||
element.setAttribute(
|
|
||||||
'style',
|
|
||||||
'background-color: ' + event_instance['bg-color'] +
|
|
||||||
'; color: ' + event_instance['fg-color']);
|
|
||||||
element.classList.add('event');
|
|
||||||
element.setAttribute('href', event_instance['url']);
|
|
||||||
element.dataset.eventInstanceId = event_instance['id'];
|
|
||||||
|
|
||||||
time_element = document.createElement('small');
|
|
||||||
time_element.innerHTML = event_instance.from.slice(11, 16) + " - " + event_instance.to.slice(11, 16);
|
|
||||||
|
|
||||||
title_element = document.createElement('p');
|
|
||||||
title_element.innerHTML = event_instance['title'];
|
|
||||||
|
|
||||||
icon_element = document.createElement('i');
|
|
||||||
icon_element.classList.add('fa-' + event_instance['location_icon']);
|
|
||||||
icon_element.classList.add('fa');
|
|
||||||
icon_element.classList.add('pull-right');
|
|
||||||
|
|
||||||
if(event_instance['video_url'] != undefined) {
|
|
||||||
video_url_element = document.createElement('i');
|
|
||||||
video_url_element.classList.add('fa-film');
|
|
||||||
video_url_element.classList.add('fa');
|
|
||||||
video_url_element.classList.add('pull-right');
|
|
||||||
element.appendChild(video_url_element);
|
|
||||||
} else if(event_instance['video_recording'] == true) {
|
|
||||||
video_recording_element = document.createElement('i');
|
|
||||||
video_recording_element.classList.add('fa-video-camera');
|
|
||||||
video_recording_element.classList.add('fa');
|
|
||||||
video_recording_element.classList.add('pull-right');
|
|
||||||
element.appendChild(video_recording_element);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.appendChild(time_element);
|
|
||||||
element.appendChild(icon_element);
|
|
||||||
element.appendChild(title_element);
|
|
||||||
|
|
||||||
element.onclick = openModal
|
|
||||||
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
function get_instances(types, locations, day) {
|
|
||||||
var event_instances = EVENT_INSTANCES.slice(0);
|
|
||||||
if(day != undefined && day != null) {
|
|
||||||
event_instances = event_instances.filter(
|
|
||||||
function(event_instance) {
|
|
||||||
return event_instance.from.slice(0, 10) == day;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if(locations.length != 0) {
|
|
||||||
event_instances = event_instances.filter(
|
|
||||||
function(event_instance) {
|
|
||||||
return locations.includes(event_instance['location']);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if(types.length != 0) {
|
|
||||||
event_instances = event_instances.filter(
|
|
||||||
function(event_instance) {
|
|
||||||
return types.includes(event_instance['event_type']);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return event_instances
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Avoid that clicking the text in the event will bring up an empty modal
|
|
||||||
target = e.target;
|
|
||||||
if (e.target !== this) {
|
|
||||||
target = e.target.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
event_instance_id = target.dataset.eventInstanceId;
|
|
||||||
event_instance = EVENT_INSTANCES.filter(
|
|
||||||
function(event_instance) {
|
|
||||||
return event_instance.id == event_instance_id
|
|
||||||
}
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
modal = modals[event_instance_id];
|
|
||||||
|
|
||||||
if(modal == undefined) {
|
|
||||||
modal = document.createElement('div');
|
|
||||||
modal.classList.add('modal');
|
|
||||||
modal.setAttribute('tabindex', '-1');
|
|
||||||
modal.setAttribute('role', 'dialog');
|
|
||||||
|
|
||||||
modal_dialog = document.createElement('div');
|
|
||||||
modal_dialog.classList.add('modal-dialog');
|
|
||||||
modal.setAttribute('role', 'document');
|
|
||||||
modal.appendChild(modal_dialog);
|
|
||||||
|
|
||||||
modal_content = document.createElement('div');
|
|
||||||
modal_content.classList.add('modal-content');
|
|
||||||
modal_dialog.appendChild(modal_content);
|
|
||||||
|
|
||||||
modal_header = document.createElement('div');
|
|
||||||
modal_header.classList.add('modal-header');
|
|
||||||
modal_content.appendChild(modal_header);
|
|
||||||
|
|
||||||
modal_close_button = document.createElement('button');
|
|
||||||
modal_close_button.setAttribute('type', 'button');
|
|
||||||
modal_close_button.setAttribute('aria-label', 'Close');
|
|
||||||
modal_close_button.dataset.dismiss = 'modal';
|
|
||||||
modal_close_button.classList.add('close');
|
|
||||||
modal_close_button.innerHTML = '<span aria-hidden="true">×</span></button>';
|
|
||||||
|
|
||||||
modal_title = document.createElement('h4');
|
|
||||||
modal_title.classList.add('modal-title')
|
|
||||||
modal_title.innerHTML = event_instance['title'];
|
|
||||||
|
|
||||||
modal_header.appendChild(modal_close_button);
|
|
||||||
modal_header.appendChild(modal_title);
|
|
||||||
|
|
||||||
modal_body_content = document.createElement('div');
|
|
||||||
modal_body_content.classList.add('modal-body');
|
|
||||||
modal_body_content.classList.add('modal-body-content');
|
|
||||||
modal_body_content.innerHTML = event_instance['abstract'];
|
|
||||||
modal_content.appendChild(modal_body_content);
|
|
||||||
|
|
||||||
modal_body = document.createElement('div');
|
|
||||||
modal_body.classList.add('modal-body');
|
|
||||||
speakers_h4 = document.createElement('h4');
|
|
||||||
speakers_h4.innerHTML = 'Speaker(s):';
|
|
||||||
speakers_ul = document.createElement('ul');
|
|
||||||
speakers_ul.classList.add('speakers');
|
|
||||||
|
|
||||||
speakers = event_instance['speakers'];
|
|
||||||
for(speaker_id in speakers) {
|
|
||||||
var speaker = speakers[speaker_id];
|
|
||||||
var speaker_li = document.createElement('li');
|
|
||||||
var speaker_a = document.createElement('a');
|
|
||||||
speaker_a.setAttribute('href', speaker['url']);
|
|
||||||
speaker_a.appendChild(document.createTextNode(speaker['name']));
|
|
||||||
speaker_li.appendChild(speaker_a);
|
|
||||||
speakers_ul.appendChild(speaker_li);
|
|
||||||
}
|
|
||||||
|
|
||||||
video_recording_div = document.createElement('div');
|
|
||||||
video_recording_div.classList.add('alert');
|
|
||||||
video_recording_div.classList.add('alert-info');
|
|
||||||
video_recording_div.classList.add('video-recording');
|
|
||||||
|
|
||||||
if(event_instance['video_url'] != undefined) {
|
|
||||||
// We have an URL to the video
|
|
||||||
video_url_icon = document.createElement('i');
|
|
||||||
video_url_icon.classList.add('fa');
|
|
||||||
video_url_icon.classList.add('fa-film');
|
|
||||||
video_url_link = document.createElement('a');
|
|
||||||
video_url_link.setAttribute('href', event_instance['video_url']);
|
|
||||||
video_url_link.setAttribute('target', '_blank');
|
|
||||||
video_url_link.innerHTML = " Watch the video recording here!";
|
|
||||||
|
|
||||||
video_recording_div.appendChild(video_url_icon);
|
|
||||||
video_recording_div.appendChild(video_url_link);
|
|
||||||
|
|
||||||
} else if(event_instance['video_recording'] == true) {
|
|
||||||
// This instance will be recorded
|
|
||||||
video_notice_icon = document.createElement('i');
|
|
||||||
video_notice_icon.classList.add('fa');
|
|
||||||
video_notice_icon.classList.add('fa-camera');
|
|
||||||
video_notice_span = document.createElement('span');
|
|
||||||
video_notice_span.innerHTML = " This event will be recorded!";
|
|
||||||
|
|
||||||
video_recording_div.appendChild(video_notice_icon);
|
|
||||||
video_recording_div.appendChild(video_notice_span);
|
|
||||||
} else {
|
|
||||||
// This instance will NOT be recorded!
|
|
||||||
video_recording_element.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
modal_body.appendChild(speakers_h4);
|
|
||||||
modal_body.appendChild(speakers_ul);
|
|
||||||
modal_body.appendChild(video_recording_div);
|
|
||||||
|
|
||||||
modal_content.appendChild(modal_body);
|
|
||||||
|
|
||||||
modal_footer = document.createElement('div');
|
|
||||||
modal_footer.classList.add('modal-footer');
|
|
||||||
modal_content.appendChild(modal_footer);
|
|
||||||
|
|
||||||
close_button = document.createElement('button');
|
|
||||||
close_button.setAttribute('type', 'button');
|
|
||||||
close_button.classList.add('btn');
|
|
||||||
close_button.classList.add('btn-default');
|
|
||||||
close_button.classList.add('pull-left');
|
|
||||||
close_button.dataset.dismiss = "modal";
|
|
||||||
close_button.innerHTML = "Close";
|
|
||||||
modal_footer.appendChild(close_button);
|
|
||||||
|
|
||||||
favorite_button = document.createElement('a');
|
|
||||||
favorite_button.classList.add('btn');
|
|
||||||
favorite_button.classList.add('btn-success');
|
|
||||||
favorite_button.classList.add('favorite-button');
|
|
||||||
favorite_button.innerHTML = '<i class="fa fa-star"></i> Favorite</a>';
|
|
||||||
if(event_instance['is_favorited'] !== undefined) {
|
|
||||||
favorite_button.setAttribute('data-state', event_instance['is_favorited'])
|
|
||||||
toggleFavoriteButton(favorite_button);
|
|
||||||
} else {
|
|
||||||
favorite_button.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
modal_footer.appendChild(favorite_button);
|
|
||||||
|
|
||||||
more_button = document.createElement('a');
|
|
||||||
more_button.classList.add('btn');
|
|
||||||
more_button.classList.add('btn-info');
|
|
||||||
more_button.classList.add('more-button');
|
|
||||||
more_button.setAttribute('href', event_instance['url']);
|
|
||||||
more_button.innerHTML = '<i class="fa fa-info"></i> More</a>';
|
|
||||||
modal_footer.appendChild(more_button);
|
|
||||||
|
|
||||||
body = document.getElementsByTagName('body')[0];
|
|
||||||
body.appendChild(modal);
|
|
||||||
modal.setAttribute('id', 'event-modal-' + event_instance_id)
|
|
||||||
modals[event_instance_id] = modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#event-modal-' + event_instance_id).modal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init_modals(event_class_name) {
|
|
||||||
var event_elements = document.getElementsByClassName(event_class_name);
|
|
||||||
|
|
||||||
for (var event_id in event_elements) {
|
|
||||||
event_element = event_elements.item(event_id);
|
|
||||||
if(event_element != null) {
|
|
||||||
event_element.onclick = openModal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var filter = document.getElementById('filter')
|
|
||||||
filter.addEventListener('change', function(e) {
|
|
||||||
var type_input = Array.prototype.slice.call(document.querySelectorAll('.event-type-checkbox:checked'));
|
|
||||||
var types = type_input.map(function(box) {
|
|
||||||
return box.value
|
|
||||||
})
|
|
||||||
var location_input = Array.prototype.slice.call(document.querySelectorAll('.location-checkbox:checked'));
|
|
||||||
var locations = location_input.map(function(box) {
|
|
||||||
return box.value
|
|
||||||
})
|
|
||||||
|
|
||||||
toggleFilterBoxes(types, locations);
|
|
||||||
setHistoryState({
|
|
||||||
'types': types,
|
|
||||||
'locations': locations
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function setHistoryState(parts) {
|
|
||||||
|
|
||||||
var day = parts['day'];
|
|
||||||
var types = parts['types'];
|
|
||||||
var locations = parts['locations'];
|
|
||||||
|
|
||||||
var query = '?';
|
|
||||||
|
|
||||||
day = day == undefined ? findGetParameter('day') : day;
|
|
||||||
if(day != null && day != 'all-days') {
|
|
||||||
query = query + "day=" + day + "&";
|
|
||||||
}
|
|
||||||
|
|
||||||
types = types == undefined ? findGetParameter('type') : types.join(',');
|
|
||||||
if(types != null && types.length > 0) {
|
|
||||||
var type_part = 'type=' + types;
|
|
||||||
query = query + type_part + "&";
|
|
||||||
}
|
|
||||||
|
|
||||||
locations = locations == undefined ? findGetParameter('location') : locations.join(',');
|
|
||||||
if(locations != null && locations.length > 0) {
|
|
||||||
var location_part = 'location=' + locations;
|
|
||||||
query = query + location_part;
|
|
||||||
}
|
|
||||||
|
|
||||||
history.replaceState({}, '', query);
|
|
||||||
setICSButtonHref(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setICSButtonHref(query) {
|
|
||||||
// Update ICS button as well
|
|
||||||
var ics_button = document.querySelector('#ics-button');
|
|
||||||
ics_button.setAttribute('href', CONFIG['ics_button_href'] + query);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFilterBoxes(types, locations) {
|
|
||||||
var type_input = Array.prototype.slice.call(document.querySelectorAll('.event-type-checkbox'));
|
|
||||||
type_input.map(function(box) {
|
|
||||||
if(types.includes(box.value)) {
|
|
||||||
box.checked = true;
|
|
||||||
}
|
|
||||||
return box;
|
|
||||||
});
|
|
||||||
var location_input = Array.prototype.slice.call(document.querySelectorAll('.location-checkbox'));
|
|
||||||
location_input.map(function(box) {
|
|
||||||
if(locations.includes(box.value)) {
|
|
||||||
box.checked = true;
|
|
||||||
}
|
|
||||||
return box;
|
|
||||||
});
|
|
||||||
}
|
|
39
src/program/templates/noscript_schedule_view.html
Normal file
39
src/program/templates/noscript_schedule_view.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends 'program_base.html' %}
|
||||||
|
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block program_content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>When?</th>
|
||||||
|
<th>What?</th>
|
||||||
|
<th>Where?</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
|
||||||
|
{% for instance in eventinstances %}
|
||||||
|
|
||||||
|
{% ifchanged instance.when.lower.date %}
|
||||||
|
<tr>
|
||||||
|
<td colspan=3><strong>{{ instance.when.lower.date|date:"l Y-m-d" }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endifchanged %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ instance.when.lower|date:"H:i" }}-{{ instance.when.upper|date:"H:i" }}</td>
|
||||||
|
<td><a href="{% url 'event_detail' camp_slug=camp.slug slug=instance.event.slug %}">{{ instance.event.title }}</a></td>
|
||||||
|
<td>{{ instance.location.name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -2,34 +2,46 @@
|
||||||
{% load commonmark %}
|
{% load commonmark %}
|
||||||
|
|
||||||
{% block program_content %}
|
{% block program_content %}
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading" ><span style="font-size: x-large"><span style="background-color: {{ event.event_type.color }}; border: 0; color: {% if event.event_type.light_text %}white{% else %}black{% endif %}; display: inline-block; padding: 5px;">{{ event.event_type.name }}</span> {{ event.title }}</span></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<p>
|
|
||||||
{{ event.abstract|commonmark }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr>
|
<div class="row">
|
||||||
|
<noscript>
|
||||||
|
<a href="{% url "noscript_schedule_index" camp_slug=camp.slug %}" class="btn btn-primary">
|
||||||
|
Back to noscript schedule
|
||||||
|
</a>
|
||||||
|
<hr />
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4>Instances</h4>
|
<div class="row">
|
||||||
<ul class="list-group">
|
<div class="panel panel-default">
|
||||||
{% for ei in event.instances.all %}
|
<div class="panel-heading" ><span style="font-size: x-large"><span style="background-color: {{ event.event_type.color }}; border: 0; color: {% if event.event_type.light_text %}white{% else %}black{% endif %}; display: inline-block; padding: 5px;">{{ event.event_type.name }}</span> {{ event.title }}</span></div>
|
||||||
<li class="list-group-item">{{ ei.when.lower|date:"l M. d H:i" }} - {{ ei.when.upper|date:"H:i" }}</li>
|
<div class="panel-body">
|
||||||
{% empty %}
|
<p>
|
||||||
No instances scheduled yet
|
{{ event.abstract|commonmark }}
|
||||||
{% endfor %}
|
</p>
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
{% if event.speakers.exists %}
|
<h4>Instances</h4>
|
||||||
<h4>Speakers</h4>
|
<ul class="list-group">
|
||||||
<div class="list-group">
|
{% for ei in event.instances.all %}
|
||||||
{% for speaker in event.speakers.all %}
|
<li class="list-group-item">{{ ei.when.lower|date:"l M. d H:i" }} - {{ ei.when.upper|date:"H:i" }}</li>
|
||||||
<h4><a href="{% url 'speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="list-group-item">{{ speaker.name }}</a></h4>
|
{% empty %}
|
||||||
|
No instances scheduled yet
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</ul>
|
||||||
{% endif %}
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{% if event.speakers.exists %}
|
||||||
|
<h4>Speakers</h4>
|
||||||
|
<div class="list-group">
|
||||||
|
{% for speaker in event.speakers.all %}
|
||||||
|
<h4><a href="{% url 'speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="list-group-item">{{ speaker.name }}</a></h4>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock program_content %}
|
{% endblock program_content %}
|
||||||
|
|
|
@ -1,22 +1,44 @@
|
||||||
{% extends 'schedule_base.html' %}
|
{% extends 'program_base.html' %}
|
||||||
|
|
||||||
{% load commonmark %}
|
{% load commonmark %}
|
||||||
{% load staticfiles %}
|
{% load staticfiles %}
|
||||||
|
|
||||||
{% block schedule_content %}
|
{% block extra_head %}
|
||||||
|
<noscript>
|
||||||
|
<meta http-equiv="refresh" content="0; url={% url "noscript_schedule_index" camp_slug=camp.slug %}" />
|
||||||
|
</noscript>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block program_content %}
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<div class="row">
|
||||||
|
<p>
|
||||||
|
No javascript? Don't worry, we have a HTML only version of the schedule! Redirecting you there now.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{% url "noscript_schedule_index" camp_slug=camp.slug %}">
|
||||||
|
Click here if you are not redirected.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
<div id="schedule-container"></div>
|
<div id="schedule-container"></div>
|
||||||
|
|
||||||
<script src="{% static "channels/js/websocketbridge.js" %}"></script>
|
<script src="{% static "js/elm_based_schedule.js" %}"></script>
|
||||||
<script src="{% static "js/event_instance_websocket.js" %}"></script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
init(
|
var container = document.getElementById('schedule-container');
|
||||||
|
var elm_app = Elm.Main.embed(
|
||||||
|
container,
|
||||||
{ 'schedule_timeslot_length_minutes': Number('{{ schedule_timeslot_length_minutes }}')
|
{ 'schedule_timeslot_length_minutes': Number('{{ schedule_timeslot_length_minutes }}')
|
||||||
, 'schedule_midnight_offset_hours': Number('{{ schedule_midnight_offset_hours }}')
|
, 'schedule_midnight_offset_hours': Number('{{ schedule_midnight_offset_hours }}')
|
||||||
, 'ics_button_href': "{% url 'ics_view' camp_slug=camp.slug %}"
|
, 'ics_button_href': "{% url 'ics_view' camp_slug=camp.slug %}"
|
||||||
|
, 'camp_slug': "{{ camp.slug }}"
|
||||||
|
, 'websocket_server': "ws://" + window.location.host + "/schedule/"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock schedule_content %}
|
{% endblock %}
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
import icalendar
|
import icalendar
|
||||||
|
|
||||||
|
@ -37,27 +38,45 @@ logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
class ICSView(CampViewMixin, View):
|
class ICSView(CampViewMixin, View):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
eventinstances = models.EventInstance.objects.filter(event__camp=self.camp)
|
eventinstances = models.EventInstance.objects.filter(event__camp=self.camp)
|
||||||
type_ = request.GET.get('type', None)
|
|
||||||
location = request.GET.get('location', None)
|
|
||||||
|
|
||||||
if type_:
|
# Type query
|
||||||
try:
|
type_query = request.GET.get('type', None)
|
||||||
eventtype = models.EventType.objects.get(
|
if type_query:
|
||||||
slug=type_
|
type_slugs = type_query.split(',')
|
||||||
)
|
types = models.EventType.objects.filter(
|
||||||
eventinstances = eventinstances.filter(event__event_type=eventtype)
|
slug__in=type_slugs
|
||||||
except models.EventType.DoesNotExist:
|
)
|
||||||
raise Http404
|
eventinstances = eventinstances.filter(event__event_type__in=types)
|
||||||
|
|
||||||
if location:
|
# Location query
|
||||||
try:
|
location_query = request.GET.get('location', None)
|
||||||
eventlocation = models.EventLocation.objects.get(
|
if location_query:
|
||||||
slug=location,
|
location_slugs = location_query.split(',')
|
||||||
camp=self.camp,
|
locations = models.EventLocation.objects.filter(
|
||||||
)
|
slug__in=location_slugs,
|
||||||
eventinstances = eventinstances.filter(location__slug=location)
|
camp=self.camp,
|
||||||
except models.EventLocation.DoesNotExist:
|
)
|
||||||
raise Http404
|
eventinstances = eventinstances.filter(location__in=locations)
|
||||||
|
|
||||||
|
# Video recording query
|
||||||
|
video_query = request.GET.get('video', None)
|
||||||
|
if video_query:
|
||||||
|
video_states = video_query.split(',')
|
||||||
|
query_kwargs = {}
|
||||||
|
|
||||||
|
if 'has-recording' in video_states:
|
||||||
|
query_kwargs['event__video_url__isnull'] = False
|
||||||
|
|
||||||
|
if 'to-be-recorded' in video_states:
|
||||||
|
query_kwargs['event__video_recording'] = True
|
||||||
|
|
||||||
|
if 'not-to-be-recorded' in video_states:
|
||||||
|
if 'event__video_recording' in query_kwargs:
|
||||||
|
del query_kwargs['event__video_recording']
|
||||||
|
else:
|
||||||
|
query_kwargs['event__video_recording'] = False
|
||||||
|
|
||||||
|
eventinstances = eventinstances.filter(**query_kwargs)
|
||||||
|
|
||||||
cal = icalendar.Calendar()
|
cal = icalendar.Calendar()
|
||||||
for event_instance in eventinstances:
|
for event_instance in eventinstances:
|
||||||
|
@ -263,57 +282,22 @@ class EventDetailView(CampViewMixin, DetailView):
|
||||||
################## schedule #############################################
|
################## schedule #############################################
|
||||||
|
|
||||||
|
|
||||||
|
class NoScriptScheduleView(CampViewMixin, TemplateView):
|
||||||
|
template_name = "noscript_schedule_view.html"
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['eventinstances'] = models.EventInstance.objects.filter(event__camp=self.camp).order_by('when')
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleView(CampViewMixin, TemplateView):
|
class ScheduleView(CampViewMixin, TemplateView):
|
||||||
def get_template_names(self):
|
template_name = 'schedule_overview.html'
|
||||||
if 'day' in self.kwargs:
|
|
||||||
return 'schedule_day.html'
|
|
||||||
return 'schedule_overview.html'
|
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
context = super(ScheduleView, self).get_context_data(**kwargs)
|
context = super(ScheduleView, self).get_context_data(**kwargs)
|
||||||
|
|
||||||
# Do stuff if we are dealing with a day schedule
|
|
||||||
if 'day' in kwargs:
|
|
||||||
when = datetime.datetime(year=int(self.kwargs['year']), month=int(self.kwargs['month']), day=int(self.kwargs['day']))
|
|
||||||
eventinstances = models.EventInstance.objects.filter(event__in=self.camp.events.all())
|
|
||||||
skip = []
|
|
||||||
for ei in eventinstances:
|
|
||||||
if ei.schedule_date != when.date():
|
|
||||||
skip.append(ei.id)
|
|
||||||
else:
|
|
||||||
if 'type' in self.request.GET:
|
|
||||||
eventtype = models.EventType.objects.get(
|
|
||||||
slug=self.request.GET['type']
|
|
||||||
)
|
|
||||||
if ei.event.event_type != eventtype:
|
|
||||||
skip.append(ei.id)
|
|
||||||
eventinstances = eventinstances.exclude(id__in=skip).order_by('event__event_type')
|
|
||||||
if 'location' in self.request.GET:
|
|
||||||
eventlocation = models.EventLocation.objects.get(
|
|
||||||
camp=self.camp,
|
|
||||||
slug=self.request.GET['location']
|
|
||||||
)
|
|
||||||
eventinstances = eventinstances.filter(location=eventlocation)
|
|
||||||
|
|
||||||
context['eventinstances'] = eventinstances
|
|
||||||
|
|
||||||
start = when + datetime.timedelta(hours=settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS)
|
|
||||||
timeslots = []
|
|
||||||
# calculate how many timeslots we have in the schedule based on the lenght of the timeslots in minutes,
|
|
||||||
# and the number of minutes in 24 hours
|
|
||||||
for i in range(0,int((24*60)/settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES)):
|
|
||||||
timeslot = start + datetime.timedelta(minutes=i*settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES)
|
|
||||||
timeslots.append(timeslot)
|
|
||||||
context['timeslots'] = timeslots
|
|
||||||
|
|
||||||
# include the components to make the urls
|
|
||||||
context['urlyear'] = self.kwargs['year']
|
|
||||||
context['urlmonth'] = self.kwargs['month']
|
|
||||||
context['urlday'] = self.kwargs['day']
|
|
||||||
|
|
||||||
context['schedule_timeslot_length_minutes'] = settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES;
|
|
||||||
context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS;
|
context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS;
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ admin.site.register(models.CoinifyAPIRequest)
|
||||||
admin.site.register(models.Invoice)
|
admin.site.register(models.Invoice)
|
||||||
admin.site.register(models.CreditNote)
|
admin.site.register(models.CreditNote)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.CustomOrder)
|
@admin.register(models.CustomOrder)
|
||||||
class CustomOrderAdmin(admin.ModelAdmin):
|
class CustomOrderAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
|
@ -25,6 +26,7 @@ class CustomOrderAdmin(admin.ModelAdmin):
|
||||||
'paid',
|
'paid',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.ProductCategory)
|
@admin.register(models.ProductCategory)
|
||||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
|
@ -37,6 +39,7 @@ class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
'name',
|
'name',
|
||||||
'category',
|
'category',
|
||||||
|
'ticket_type',
|
||||||
'price',
|
'price',
|
||||||
'available_in',
|
'available_in',
|
||||||
]
|
]
|
||||||
|
@ -115,7 +118,6 @@ class TicketModelAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
list_filter = ['product', 'checked_in']
|
list_filter = ['product', 'checked_in']
|
||||||
|
|
||||||
|
|
||||||
actions = ['mark_as_arrived']
|
actions = ['mark_as_arrived']
|
||||||
|
|
||||||
def mark_as_arrived(self, request, queryset):
|
def mark_as_arrived(self, request, queryset):
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from shop.pdf import generate_pdf_letter
|
from utils.pdf import generate_pdf_letter
|
||||||
from shop.email import add_invoice_email, add_creditnote_email
|
from shop.email import add_invoice_email, add_creditnote_email
|
||||||
from shop.models import Order, CustomOrder, Invoice, CreditNote
|
from shop.models import Order, CustomOrder, Invoice, CreditNote
|
||||||
import logging
|
import logging
|
||||||
|
|
22
src/shop/migrations/0048_product_ticket_type.py
Normal file
22
src/shop/migrations/0048_product_ticket_type.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.10.5 on 2017-08-17 14:22
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tickets', '0001_initial'),
|
||||||
|
('shop', '0047_auto_20170522_1942'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='product',
|
||||||
|
name='ticket_type',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tickets.TicketType'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,21 +1,28 @@
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import qrcode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.aggregates import Sum
|
from django.db.models.aggregates import Sum
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.postgres.fields import DateTimeRangeField, JSONField
|
from django.contrib.postgres.fields import DateTimeRangeField, JSONField
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.urlresolvers import reverse_lazy
|
from django.core.urlresolvers import reverse_lazy
|
||||||
from utils.models import UUIDModel, CreatedUpdatedModel
|
|
||||||
from .managers import ProductQuerySet, OrderQuerySet
|
|
||||||
import hashlib, io, base64, qrcode
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils import timezone
|
|
||||||
|
from utils.models import UUIDModel, CreatedUpdatedModel
|
||||||
|
from tickets.models import ShopTicket
|
||||||
|
from .managers import ProductQuerySet, OrderQuerySet
|
||||||
|
|
||||||
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
class CustomOrder(CreatedUpdatedModel):
|
class CustomOrder(CreatedUpdatedModel):
|
||||||
|
@ -77,7 +84,7 @@ class Order(CreatedUpdatedModel):
|
||||||
CREDIT_CARD = 'credit_card'
|
CREDIT_CARD = 'credit_card'
|
||||||
BLOCKCHAIN = 'blockchain'
|
BLOCKCHAIN = 'blockchain'
|
||||||
BANK_TRANSFER = 'bank_transfer'
|
BANK_TRANSFER = 'bank_transfer'
|
||||||
CASH = 'cash'
|
CASH = 'cash'
|
||||||
|
|
||||||
PAYMENT_METHODS = [
|
PAYMENT_METHODS = [
|
||||||
CREDIT_CARD,
|
CREDIT_CARD,
|
||||||
|
@ -115,7 +122,6 @@ class Order(CreatedUpdatedModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
objects = OrderQuerySet.as_manager()
|
objects = OrderQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -176,7 +182,8 @@ class Order(CreatedUpdatedModel):
|
||||||
for order_product in self.orderproductrelation_set.all():
|
for order_product in self.orderproductrelation_set.all():
|
||||||
if order_product.product.category.name == "Tickets":
|
if order_product.product.category.name == "Tickets":
|
||||||
for _ in range(0, order_product.quantity):
|
for _ in range(0, order_product.quantity):
|
||||||
ticket = Ticket(
|
ticket = ShopTicket(
|
||||||
|
ticket_type=order_product.product.ticket_type,
|
||||||
order=self,
|
order=self,
|
||||||
product=order_product.product,
|
product=order_product.product,
|
||||||
)
|
)
|
||||||
|
@ -291,6 +298,12 @@ class Product(CreatedUpdatedModel, UUIDModel):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ticket_type = models.ForeignKey(
|
||||||
|
'tickets.TicketType',
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
objects = ProductQuerySet.as_manager()
|
objects = ProductQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -514,4 +527,3 @@ class Ticket(CreatedUpdatedModel, UUIDModel):
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return str(reverse_lazy('shop:ticket_detail', kwargs={'pk': self.pk}))
|
return str(reverse_lazy('shop:ticket_detail', kwargs={'pk': self.pk}))
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
body {
|
body {
|
||||||
margin-top: 85px;
|
margin-top: 85px;
|
||||||
margin-bottom: 35px;
|
margin-bottom: 35px;
|
||||||
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
@ -148,27 +149,43 @@ footer {
|
||||||
.event {
|
.event {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
margin: 5px;
|
|
||||||
width: 200px;
|
|
||||||
max-width: 200px;
|
|
||||||
min-width: 200px;
|
|
||||||
min-height: 100px;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-td {
|
.event-in-overview {
|
||||||
padding: 5px;
|
min-height: 100px;
|
||||||
vertical-align: top;
|
margin: 5px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
flex-grow: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-td a {
|
.location-column {
|
||||||
display: block;
|
position: relative;
|
||||||
|
margin: 0 2px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-column-header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 50px;
|
||||||
|
font-size: 20px;
|
||||||
|
background-color: #eee;
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-column-slot {
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view-gutter {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-in-dayview {
|
||||||
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -179,16 +196,12 @@ footer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event:hover, .event-td:hover {
|
.event:hover {
|
||||||
background-color: black !important;
|
background-color: black !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-td a:hover {
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-select {
|
.fa-select {
|
||||||
font-family: 'FontAwesome','Helvetica Neue',Helvetica,Arial,sans-serif;
|
font-family: 'FontAwesome','Helvetica Neue',Helvetica,Arial,sans-serif;
|
||||||
}
|
}
|
||||||
|
@ -208,13 +221,28 @@ footer {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-filter .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (min-width: 520px) {
|
@media (min-width: 520px) {
|
||||||
.schedule-filter {
|
.schedule-sidebar {
|
||||||
border-left: 1px solid #eee;
|
border-left: 1px solid #eee;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#daypicker {
|
||||||
|
top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
#schedule-days {
|
#schedule-days {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -231,28 +259,7 @@ footer {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group .schedule-checkbox {
|
.filter-choice-active {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="checkbox"] + .btn-group > label span {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="checkbox"] + .btn-group > label span i:first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.form-group input[type="checkbox"] + .btn-group > label span i:last-child {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="checkbox"]:checked + .btn-group > label span i:first-child {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.form-group input[type="checkbox"]:checked + .btn-group > label span i:last-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.form-group input[type="checkbox"]:checked + .btn-group > label {
|
|
||||||
color: #333;
|
color: #333;
|
||||||
background-color: #e6e6e6;
|
background-color: #e6e6e6;
|
||||||
border-color: #adadad;
|
border-color: #adadad;
|
||||||
|
|
0
src/tickets/__init__.py
Normal file
0
src/tickets/__init__.py
Normal file
38
src/tickets/admin.py
Normal file
38
src/tickets/admin.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
TicketType,
|
||||||
|
SponsorTicket,
|
||||||
|
DiscountTicket,
|
||||||
|
ShopTicket
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTicketAdmin(admin.ModelAdmin):
|
||||||
|
actions = ['generate_pdf']
|
||||||
|
exclude = ['qrcode_base64']
|
||||||
|
|
||||||
|
def generate_pdf(self, request, queryset):
|
||||||
|
for ticket in queryset.all():
|
||||||
|
ticket.generate_pdf()
|
||||||
|
generate_pdf.description = 'Generate PDF for the ticket'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketType)
|
||||||
|
class TicketTypeAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SponsorTicket)
|
||||||
|
class SponsorTicketAdmin(BaseTicketAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DiscountTicket)
|
||||||
|
class DiscountTicketAdmin(BaseTicketAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ShopTicket)
|
||||||
|
class ShopTicketAdmin(BaseTicketAdmin):
|
||||||
|
pass
|
5
src/tickets/apps.py
Normal file
5
src/tickets/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TicketsConfig(AppConfig):
|
||||||
|
name = 'tickets'
|
96
src/tickets/migrations/0001_initial.py
Normal file
96
src/tickets/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.10.5 on 2017-08-17 14:22
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0047_auto_20170522_1942'),
|
||||||
|
('camps', '0022_camp_colour'),
|
||||||
|
('sponsors', '0006_auto_20170715_1110'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BaseTicket',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('qrcode_base64', models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketType',
|
||||||
|
fields=[
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.TextField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DiscountTicket',
|
||||||
|
fields=[
|
||||||
|
('baseticket_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tickets.BaseTicket')),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
('price', models.IntegerField(help_text='Price of the discounted ticket (in DKK, including VAT).')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('tickets.baseticket', models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ShopTicket',
|
||||||
|
fields=[
|
||||||
|
('baseticket_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tickets.BaseTicket')),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(blank=True, help_text='Name of the person this ticket belongs to. This can be different from the buying user.', max_length=100, null=True)),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||||
|
('checked_in', models.BooleanField(default=False)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shoptickets', to='shop.Order')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.Product')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('tickets.baseticket', models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SponsorTicket',
|
||||||
|
fields=[
|
||||||
|
('baseticket_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='tickets.BaseTicket')),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
|
('sponsor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sponsors.Sponsor')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=('tickets.baseticket', models.Model),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='baseticket',
|
||||||
|
name='camp',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='camps.Camp'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='baseticket',
|
||||||
|
name='ticket_type',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.TicketType'),
|
||||||
|
),
|
||||||
|
]
|
0
src/tickets/migrations/__init__.py
Normal file
0
src/tickets/migrations/__init__.py
Normal file
128
src/tickets/models.py
Normal file
128
src/tickets/models.py
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.urlresolvers import reverse_lazy
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from utils.models import (
|
||||||
|
UUIDModel,
|
||||||
|
CreatedUpdatedModel
|
||||||
|
)
|
||||||
|
from utils.pdf import generate_pdf_letter
|
||||||
|
|
||||||
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# TicketType can be full week, one day. etc.
|
||||||
|
class TicketType(CreatedUpdatedModel, UUIDModel):
|
||||||
|
name = models.TextField()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '{}'.format(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTicket(models.Model):
|
||||||
|
qrcode_base64 = models.TextField(null=True, blank=True)
|
||||||
|
ticket_type = models.ForeignKey('TicketType')
|
||||||
|
camp = models.ForeignKey('camps.Camp')
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
super(BaseTicket, self).save(**kwargs)
|
||||||
|
self.qrcode_base64 = self.get_qr_code()
|
||||||
|
super(BaseTicket, self).save(**kwargs)
|
||||||
|
|
||||||
|
def _get_token(self):
|
||||||
|
return hashlib.sha256(
|
||||||
|
'{_id}{secret_key}'.format(
|
||||||
|
_id=self.pk,
|
||||||
|
secret_key=settings.SECRET_KEY,
|
||||||
|
).encode('utf-8')
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
def get_qr_code(self):
|
||||||
|
qr = qrcode.make(
|
||||||
|
self._get_token(),
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_H
|
||||||
|
).resize((250, 250))
|
||||||
|
file_like = io.BytesIO()
|
||||||
|
qr.save(file_like, format='png')
|
||||||
|
qrcode_base64 = base64.b64encode(file_like.getvalue())
|
||||||
|
return qrcode_base64
|
||||||
|
|
||||||
|
def get_qr_code_url(self):
|
||||||
|
return 'data:image/png;base64,{}'.format(self.qrcode_base64)
|
||||||
|
|
||||||
|
def generate_pdf(self):
|
||||||
|
generate_pdf_letter(
|
||||||
|
filename='ticket_{}.pdf'.format(self.pk),
|
||||||
|
formatdict={'ticket': self},
|
||||||
|
template='pdf/ticket.html'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorTicket(BaseTicket, CreatedUpdatedModel, UUIDModel):
|
||||||
|
sponsor = models.ForeignKey('sponsors.Sponsor')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'SponsorTicket: {}'.format(self.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountTicket(BaseTicket, CreatedUpdatedModel, UUIDModel):
|
||||||
|
price = models.IntegerField(
|
||||||
|
help_text=_('Price of the discounted ticket (in DKK, including VAT).')
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'DiscountTicket: {}'.format(self.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopTicket(BaseTicket, CreatedUpdatedModel, UUIDModel):
|
||||||
|
order = models.ForeignKey('shop.Order', related_name='shoptickets')
|
||||||
|
product = models.ForeignKey('shop.Product')
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text=(
|
||||||
|
'Name of the person this ticket belongs to. '
|
||||||
|
'This can be different from the buying user.'
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
email = models.EmailField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
checked_in = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# overwrite the _get_token method because old tickets use the user_id
|
||||||
|
def _get_token(self):
|
||||||
|
return hashlib.sha256(
|
||||||
|
'{_id}{user_id}{secret_key}'.format(
|
||||||
|
_id=self.pk,
|
||||||
|
user_id=self.order.user.pk,
|
||||||
|
secret_key=settings.SECRET_KEY,
|
||||||
|
).encode('utf-8')
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'Ticket {user} {product}'.format(
|
||||||
|
user=self.order.user,
|
||||||
|
product=self.product
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
super(ShopTicket, self).save(**kwargs)
|
||||||
|
self.qrcode_base64 = self.get_qr_code()
|
||||||
|
super(ShopTicket, self).save(**kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return str(reverse_lazy('shop:ticket_detail', kwargs={'pk': self.pk}))
|
0
src/tickets/pdf.py
Normal file
0
src/tickets/pdf.py
Normal file
32
src/tickets/templates/pdf/ticket.html
Normal file
32
src/tickets/templates/pdf/ticket.html
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
|
||||||
|
<table style="width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 75%;"> </td>
|
||||||
|
<td>
|
||||||
|
<h3>
|
||||||
|
{{ ticket.created|date:"b jS, Y" }}<br>
|
||||||
|
</h3>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<br>
|
||||||
|
<h2>{{ ticket.camp.title }} Ticket</h2>
|
||||||
|
<h3>Type: {{ ticket.ticket_type }}</h3>
|
||||||
|
|
||||||
|
{% if ticket.name %}
|
||||||
|
<h3>Participant: {{ ticket.name }}</h3>
|
||||||
|
<br>
|
||||||
|
{% elif ticket.order.user.email %}
|
||||||
|
<h3>Participant: {{ ticket.order.user.email }}</h3>
|
||||||
|
<br>
|
||||||
|
{% elif ticket.sponsor %}
|
||||||
|
<h3>Sponsor: {{ ticket.sponsor.name }} </h3>
|
||||||
|
<img src="{{ ticket.sponsor.logo }}"></img>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<center>
|
||||||
|
<img src="{{ ticket.get_qr_code_url }}"></img>
|
||||||
|
<p>Ticket #{{ ticket.pk }}</p>
|
||||||
|
</center>
|
3
src/tickets/views.py
Normal file
3
src/tickets/views.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
|
@ -51,6 +51,7 @@ class Command(BaseCommand):
|
||||||
timezone.datetime(2016, 9, 4, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2016, 9, 4, 12, 0, tzinfo=timezone.utc),
|
||||||
timezone.datetime(2016, 9, 6, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2016, 9, 6, 12, 0, tzinfo=timezone.utc),
|
||||||
),
|
),
|
||||||
|
colour='#000000',
|
||||||
)
|
)
|
||||||
|
|
||||||
camp2017 = Camp.objects.create(
|
camp2017 = Camp.objects.create(
|
||||||
|
@ -69,6 +70,7 @@ class Command(BaseCommand):
|
||||||
timezone.datetime(2017, 9, 4, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2017, 9, 4, 12, 0, tzinfo=timezone.utc),
|
||||||
timezone.datetime(2017, 9, 6, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2017, 9, 6, 12, 0, tzinfo=timezone.utc),
|
||||||
),
|
),
|
||||||
|
colour='#000000',
|
||||||
)
|
)
|
||||||
|
|
||||||
camp2018 = Camp.objects.create(
|
camp2018 = Camp.objects.create(
|
||||||
|
@ -87,6 +89,7 @@ class Command(BaseCommand):
|
||||||
timezone.datetime(2018, 9, 4, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2018, 9, 4, 12, 0, tzinfo=timezone.utc),
|
||||||
timezone.datetime(2018, 9, 6, 12, 0, tzinfo=timezone.utc),
|
timezone.datetime(2018, 9, 6, 12, 0, tzinfo=timezone.utc),
|
||||||
),
|
),
|
||||||
|
colour='#000000',
|
||||||
)
|
)
|
||||||
|
|
||||||
self.output("Creating users...")
|
self.output("Creating users...")
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
|
import logging
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from wkhtmltopdf.views import PDFTemplateResponse
|
from wkhtmltopdf.views import PDFTemplateResponse
|
||||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import logging
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
logger = logging.getLogger("bornhack.%s" % __name__)
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,7 +37,14 @@ def generate_pdf_letter(filename, template, formatdict):
|
||||||
|
|
||||||
# get watermark from watermark file
|
# get watermark from watermark file
|
||||||
watermark = PdfFileReader(
|
watermark = PdfFileReader(
|
||||||
open(os.path.join(settings.STATICFILES_DIRS[0], 'pdf', settings.PDF_LETTERHEAD_FILENAME), 'rb')
|
open(
|
||||||
|
os.path.join(
|
||||||
|
settings.STATICFILES_DIRS[0],
|
||||||
|
'pdf',
|
||||||
|
settings.PDF_LETTERHEAD_FILENAME
|
||||||
|
),
|
||||||
|
'rb'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# add the watermark to all pages
|
# add the watermark to all pages
|
||||||
|
@ -60,4 +68,3 @@ def generate_pdf_letter(filename, template, formatdict):
|
||||||
returnfile = io.BytesIO()
|
returnfile = io.BytesIO()
|
||||||
finalpdf.write(returnfile)
|
finalpdf.write(returnfile)
|
||||||
return returnfile
|
return returnfile
|
||||||
|
|
Loading…
Reference in a new issue