Merge branch 'master' of github.com:bornhack/bornhack-website

This commit is contained in:
Stephan Telling 2017-08-17 19:10:20 +02:00
commit 86ec617b71
44 changed files with 19245 additions and 746 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ db.sqlite3
*.pyc
venv/
environment_settings.py
elm-stuff/

5
schedule/Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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..." ]

View 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
]

View 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
]

View 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)
)
]

View 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

View 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

View 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 ] ]

View 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 )
]
)
)

View file

@ -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

View file

@ -42,6 +42,7 @@ INSTALLED_APPS = [
'ircbot',
'teams',
'people',
'tickets',
'allauth',
'allauth.account',

View file

@ -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"
),

View file

@ -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):

View file

@ -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')

File diff suppressed because one or more lines are too long

View file

@ -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">&times;</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;
});
}

View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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

View file

@ -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):

View file

@ -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

View 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'),
),
]

View file

@ -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}))

View file

@ -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
View file

38
src/tickets/admin.py Normal file
View 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
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class TicketsConfig(AppConfig):
name = 'tickets'

View 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'),
),
]

View file

128
src/tickets/models.py Normal file
View 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
View file

View 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%;">&nbsp;</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
View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View file

@ -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...")

View file

@ -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