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
|
||||
venv/
|
||||
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'
|
||||
|
||||
# schedule settings
|
||||
SCHEDULE_MIDNIGHT_OFFSET_HOURS=6
|
||||
SCHEDULE_MIDNIGHT_OFFSET_HOURS=9
|
||||
SCHEDULE_TIMESLOT_LENGTH_MINUTES=30
|
||||
SCHEDULE_EVENT_NOTIFICATION_MINUTES=10
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||
'ircbot',
|
||||
'teams',
|
||||
'people',
|
||||
'tickets',
|
||||
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
|
|
|
@ -127,16 +127,16 @@ urlpatterns = [
|
|||
|
||||
url(
|
||||
r'^program/', include([
|
||||
url(
|
||||
r'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/$',
|
||||
ScheduleView.as_view(),
|
||||
name='schedule_day'
|
||||
),
|
||||
url(
|
||||
r'^$',
|
||||
ScheduleView.as_view(),
|
||||
name='schedule_index'
|
||||
),
|
||||
url(
|
||||
r'^noscript/$',
|
||||
NoScriptScheduleView.as_view(),
|
||||
name='noscript_schedule_index'
|
||||
),
|
||||
url(
|
||||
r'^ics/', ICSView.as_view(), name="ics_view"
|
||||
),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from channels.generic.websockets import JsonWebsocketConsumer
|
||||
|
||||
from camps.models import Camp
|
||||
from .models import EventInstance, Favorite
|
||||
from .models import Event, EventInstance, Favorite, EventLocation, EventType, Speaker
|
||||
|
||||
|
||||
class ScheduleConsumer(JsonWebsocketConsumer):
|
||||
|
@ -10,34 +10,52 @@ class ScheduleConsumer(JsonWebsocketConsumer):
|
|||
def connection_groups(self, **kwargs):
|
||||
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):
|
||||
content = self.decode_json(message['text'])
|
||||
action = content.get('action')
|
||||
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':
|
||||
event_instance_id = content.get('event_instance_id')
|
||||
event_instance = EventInstance.objects.get(id=event_instance_id)
|
||||
|
@ -52,6 +70,7 @@ class ScheduleConsumer(JsonWebsocketConsumer):
|
|||
favorite = Favorite.objects.get(event_instance=event_instance, user=message.user)
|
||||
favorite.delete()
|
||||
|
||||
if data:
|
||||
self.send(data)
|
||||
|
||||
def disconnect(self, message, **kwargs):
|
||||
|
|
|
@ -14,6 +14,8 @@ from django.dispatch import receiver
|
|||
from django.utils.text import slugify
|
||||
from django.conf import settings
|
||||
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.urls import reverse
|
||||
from django.apps import apps
|
||||
|
@ -311,6 +313,13 @@ class EventLocation(CampRelatedModel):
|
|||
class Meta:
|
||||
unique_together = (('camp', 'slug'), ('camp', 'name'))
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"icon": self.icon,
|
||||
}
|
||||
|
||||
|
||||
class EventType(CreatedUpdatedModel):
|
||||
""" Every event needs to have a type. """
|
||||
|
@ -350,6 +359,14 @@ class EventType(CreatedUpdatedModel):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"color": self.color,
|
||||
"light_text": self.light_text,
|
||||
}
|
||||
|
||||
|
||||
class Event(CampRelatedModel):
|
||||
""" Something that is on the program one or more times. """
|
||||
|
@ -413,6 +430,30 @@ class Event(CampRelatedModel):
|
|||
def get_absolute_url(self):
|
||||
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):
|
||||
""" An instance of an event """
|
||||
|
@ -475,35 +516,32 @@ class EventInstance(CampRelatedModel):
|
|||
ievent['location'] = icalendar.vText(self.location.name)
|
||||
return ievent
|
||||
|
||||
def to_json(self, user=None):
|
||||
parser = CommonMark.Parser()
|
||||
renderer = CommonMark.HtmlRenderer()
|
||||
ast = parser.parse(self.event.abstract)
|
||||
abstract = renderer.render(ast)
|
||||
|
||||
def serialize(self, user=None):
|
||||
data = {
|
||||
'title': self.event.title,
|
||||
'slug': self.event.slug + '-' + str(self.id),
|
||||
'event_slug': self.event.slug,
|
||||
'abstract': abstract,
|
||||
'from': self.when.lower.astimezone().isoformat(),
|
||||
'to': self.when.upper.astimezone().isoformat(),
|
||||
'url': str(self.event.get_absolute_url()),
|
||||
'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,
|
||||
'fg-color': '#fff' if self.event.event_type.light_text else '#000',
|
||||
'event_type': self.event.event_type.slug,
|
||||
'location': self.location.slug,
|
||||
'location_icon': self.location.icon,
|
||||
'timeslots': self.timeslots,
|
||||
'video_recording': self.event.video_recording,
|
||||
}
|
||||
|
||||
if self.event.video_url:
|
||||
video_state = 'has-recording'
|
||||
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:
|
||||
is_favorited = user.favorites.filter(event_instance=self).exists()
|
||||
|
@ -589,6 +627,29 @@ class Speaker(CampRelatedModel):
|
|||
def get_absolute_url(self):
|
||||
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):
|
||||
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,6 +2,17 @@
|
|||
{% load commonmark %}
|
||||
|
||||
{% block program_content %}
|
||||
|
||||
<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>
|
||||
|
||||
<div class="row">
|
||||
<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">
|
||||
|
@ -32,5 +43,6 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock program_content %}
|
||||
|
||||
|
|
|
@ -1,22 +1,44 @@
|
|||
{% extends 'schedule_base.html' %}
|
||||
{% extends 'program_base.html' %}
|
||||
|
||||
{% load commonmark %}
|
||||
{% 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>
|
||||
|
||||
<script src="{% static "channels/js/websocketbridge.js" %}"></script>
|
||||
<script src="{% static "js/event_instance_websocket.js" %}"></script>
|
||||
<script src="{% static "js/elm_based_schedule.js" %}"></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_midnight_offset_hours': Number('{{ schedule_midnight_offset_hours }}')
|
||||
, 'ics_button_href': "{% url 'ics_view' camp_slug=camp.slug %}"
|
||||
, 'camp_slug': "{{ camp.slug }}"
|
||||
, 'websocket_server': "ws://" + window.location.host + "/schedule/"
|
||||
}
|
||||
);
|
||||
</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 import messages
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
import icalendar
|
||||
|
||||
|
@ -37,27 +38,45 @@ logger = logging.getLogger("bornhack.%s" % __name__)
|
|||
class ICSView(CampViewMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
eventinstances = models.EventInstance.objects.filter(event__camp=self.camp)
|
||||
type_ = request.GET.get('type', None)
|
||||
location = request.GET.get('location', None)
|
||||
|
||||
if type_:
|
||||
try:
|
||||
eventtype = models.EventType.objects.get(
|
||||
slug=type_
|
||||
# Type query
|
||||
type_query = request.GET.get('type', None)
|
||||
if type_query:
|
||||
type_slugs = type_query.split(',')
|
||||
types = models.EventType.objects.filter(
|
||||
slug__in=type_slugs
|
||||
)
|
||||
eventinstances = eventinstances.filter(event__event_type=eventtype)
|
||||
except models.EventType.DoesNotExist:
|
||||
raise Http404
|
||||
eventinstances = eventinstances.filter(event__event_type__in=types)
|
||||
|
||||
if location:
|
||||
try:
|
||||
eventlocation = models.EventLocation.objects.get(
|
||||
slug=location,
|
||||
# Location query
|
||||
location_query = request.GET.get('location', None)
|
||||
if location_query:
|
||||
location_slugs = location_query.split(',')
|
||||
locations = models.EventLocation.objects.filter(
|
||||
slug__in=location_slugs,
|
||||
camp=self.camp,
|
||||
)
|
||||
eventinstances = eventinstances.filter(location__slug=location)
|
||||
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()
|
||||
for event_instance in eventinstances:
|
||||
|
@ -263,57 +282,22 @@ class EventDetailView(CampViewMixin, DetailView):
|
|||
################## 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):
|
||||
def get_template_names(self):
|
||||
if 'day' in self.kwargs:
|
||||
return 'schedule_day.html'
|
||||
return 'schedule_overview.html'
|
||||
template_name = 'schedule_overview.html'
|
||||
|
||||
def get_context_data(self, *args, **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;
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ admin.site.register(models.CoinifyAPIRequest)
|
|||
admin.site.register(models.Invoice)
|
||||
admin.site.register(models.CreditNote)
|
||||
|
||||
|
||||
@admin.register(models.CustomOrder)
|
||||
class CustomOrderAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
|
@ -25,6 +26,7 @@ class CustomOrderAdmin(admin.ModelAdmin):
|
|||
'paid',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.ProductCategory)
|
||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
|
@ -37,6 +39,7 @@ class ProductAdmin(admin.ModelAdmin):
|
|||
list_display = [
|
||||
'name',
|
||||
'category',
|
||||
'ticket_type',
|
||||
'price',
|
||||
'available_in',
|
||||
]
|
||||
|
@ -115,7 +118,6 @@ class TicketModelAdmin(admin.ModelAdmin):
|
|||
|
||||
list_filter = ['product', 'checked_in']
|
||||
|
||||
|
||||
actions = ['mark_as_arrived']
|
||||
|
||||
def mark_as_arrived(self, request, queryset):
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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.models import Order, CustomOrder, Invoice, CreditNote
|
||||
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.db import models
|
||||
from django.db.models.aggregates import Sum
|
||||
from django.contrib import messages
|
||||
from django.contrib.postgres.fields import DateTimeRangeField, JSONField
|
||||
from django.http import HttpResponse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone
|
||||
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 datetime import timedelta
|
||||
from unidecode import unidecode
|
||||
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):
|
||||
|
@ -115,7 +122,6 @@ class Order(CreatedUpdatedModel):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
objects = OrderQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
|
@ -176,7 +182,8 @@ class Order(CreatedUpdatedModel):
|
|||
for order_product in self.orderproductrelation_set.all():
|
||||
if order_product.product.category.name == "Tickets":
|
||||
for _ in range(0, order_product.quantity):
|
||||
ticket = Ticket(
|
||||
ticket = ShopTicket(
|
||||
ticket_type=order_product.product.ticket_type,
|
||||
order=self,
|
||||
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()
|
||||
|
||||
def __str__(self):
|
||||
|
@ -514,4 +527,3 @@ class Ticket(CreatedUpdatedModel, UUIDModel):
|
|||
|
||||
def get_absolute_url(self):
|
||||
return str(reverse_lazy('shop:ticket_detail', kwargs={'pk': self.pk}))
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
body {
|
||||
margin-top: 85px;
|
||||
margin-bottom: 35px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
* {
|
||||
|
@ -148,27 +149,43 @@ footer {
|
|||
.event {
|
||||
padding: 5px;
|
||||
vertical-align: top;
|
||||
margin: 5px;
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
min-width: 200px;
|
||||
min-height: 100px;
|
||||
flex-grow: 1;
|
||||
border: 1px solid black;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-td {
|
||||
padding: 5px;
|
||||
vertical-align: top;
|
||||
.event-in-overview {
|
||||
min-height: 100px;
|
||||
margin: 5px;
|
||||
max-width: 200px;
|
||||
min-width: 200px;
|
||||
flex-grow: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-td a {
|
||||
display: block;
|
||||
.location-column {
|
||||
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;
|
||||
color: white !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.event-td a:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.fa-select {
|
||||
font-family: 'FontAwesome','Helvetica Neue',Helvetica,Arial,sans-serif;
|
||||
}
|
||||
|
@ -208,13 +221,28 @@ footer {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.schedule-filter .btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.schedule-filter {
|
||||
.schedule-sidebar {
|
||||
border-left: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
background-color: #fff;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#daypicker {
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
#schedule-days {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
@ -231,28 +259,7 @@ footer {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group .schedule-checkbox {
|
||||
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 {
|
||||
.filter-choice-active {
|
||||
color: #333;
|
||||
background-color: #e6e6e6;
|
||||
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, 6, 12, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
colour='#000000',
|
||||
)
|
||||
|
||||
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, 6, 12, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
colour='#000000',
|
||||
)
|
||||
|
||||
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, 6, 12, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
colour='#000000',
|
||||
)
|
||||
|
||||
self.output("Creating users...")
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import logging
|
||||
import io
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from wkhtmltopdf.views import PDFTemplateResponse
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
from django.test.client import RequestFactory
|
||||
from django.conf import settings
|
||||
import logging
|
||||
import io
|
||||
import os
|
||||
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||
|
||||
|
||||
|
@ -36,7 +37,14 @@ def generate_pdf_letter(filename, template, formatdict):
|
|||
|
||||
# get watermark from watermark file
|
||||
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
|
||||
|
@ -60,4 +68,3 @@ def generate_pdf_letter(filename, template, formatdict):
|
|||
returnfile = io.BytesIO()
|
||||
finalpdf.write(returnfile)
|
||||
return returnfile
|
||||
|
Loading…
Reference in a new issue