diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1d932f6b --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2016-2018, BornHack +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 4494c0ea..22e9d088 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ Django project to power Bornhack. Features include news, villages, webshop, and more. -## Setup +## Development setup ### Clone the repo Clone with --recursive to include submodules: git clone --recursive https://github.com/bornhack/bornhack-website -If you already cloned the repository, you can add the submodules like this: +If you already cloned the repository without --recursive, you can change into the directory and add the submodules with: git submodule update --init --recursive @@ -20,60 +20,72 @@ $ virtualenv venv -p python3 $ source venv/bin/activate ``` +If you installed python3 using Homebrew on macOS, you will need to install virtualenv by runinng the following command first: +``` +pip3 install virtualenv +``` + ### System libraries Install system dependencies (method depends on OS): - postgresql headers (for psycopg2): - Debian: libpq-dev - FreeBSD: databases/postgresql93-client + - macOS: If using the PostgreSQL.app, the headers are included, only path needs to be added - libjpeg (for pdf generation) - Debian: libjpeg-dev - FreeBSD: graphics/jpeg-turbo + - macOS: brew install libjpeg +- libmagic (might already be installed) + - macOS: brew install libmagic - wkhtmltopdf (also for pdf generation): - Debian: wkhtmltopdf - FreeBSD: converters/wkhtmltopdf + - macOS: install from https://wkhtmltopdf.org/ - fonts - Debian: ? - FreeBSD: x11-fonts/webfonts + - macOS: ? ### Python packages Install pip packages: ``` - (venv) $ pip install -r src/requirements.txt +(venv) $ pip install -r src/requirements/dev.txt ``` +### Postgres + +You need to have a running Postgres instance (we use Postgres-specific datetime range fields). Install Postgress, and add a database `bornhack` (or whichever you like) with some way for the application to connect to it, for instance adding a user with a password. + +You can also use Unix socket connections if you know how to. It's faster, easier and perhaps more secure. + ### Configuration file -Copy environment settings file and change settings as needed: + +Copy dev environment settings file and change settings as needed: + ``` - (venv) $ cp src/bornhack/environment_settings.py.dist src/bornhack/environment_settings.py +(venv) $ cp src/bornhack/environment_settings.py.dist.dev src/bornhack/environment_settings.py ``` -Edit the configuration file, replacing all the ``{{ placeholder }}`` patterns -(intended for Ansible). +Edit the configuration file, setting up `DATABASES` matching your Postgres settings. ### Database Is this a new installation? Initialize the database: + ``` - (venv) $ src/manage.py migrate +(venv) $ src/manage.py migrate ``` Is this for local development? Bootstrap the database with dummy data and users: -``` - (venv) $ src/manage.py bootstrap-devsite -``` - -### Deploy camps+program test data - -Run this command to create a bunch of nice test data: ``` - (venv) $ src/manage.py bootstrap-devsite +(venv) $ src/manage.py bootstrap-devsite ``` ### Done Is this for local development? Start the Django devserver: ``` - (venv) $ src/manage.py runserver +(venv) $ src/manage.py runserver ``` Otherwise start uwsgi or similar to serve the application. @@ -87,7 +99,7 @@ Enjoy! Add a new camp by running: ``` - (venv) $ src/manage.py createcamp {camp-slug} +(venv) $ src/manage.py createcamp {camp-slug} ``` Then go to the admin interface to edit the camp details, adding the same slug @@ -102,8 +114,21 @@ You can also specify details like: * `{camp-slug}-logo-large.png` * `{camp-slug}-logo-small.png` -### multicamp prod migration notes - -* when villages.0008 migration fails go add camp_id to all existing villages -* go to admin interface and add bornhack 2017, and set slug for bornhack 2016 -* convert events to the new format (somehow) +## Contributors +* Alexander Færøy https://github.com/ahf +* Benjamin Bach https://github.com/benjaoming +* coral https://github.com/coral +* Henrik Kramshøj https://github.com/kramse +* Janus Troelsen https://github.com/ysangkok +* Jonty Wareing https://github.com/Jonty +* Kasper Christensen https://github.com/fALKENdk +* klarstrup https://github.com/klarstrup +* kugg https://github.com/kugg +* RadicalPet https://github.com/RadicalPet +* Reynir Björnsson https://github.com/reynir +* Ronni Elken Lindsgaard https://github.com/rlindsgaard +* Stephan Telling https://github.com/Telling +* Thomas Flummer https://github.com/flummer +* Thomas Steen Rasmusssen https://github.com/tykling +* Víðir Valberg Guðmundsson https://github.com/valberg +* Ximin Luo https://github.com/infinity0 diff --git a/schedule/src/Decoders.elm b/schedule/src/Decoders.elm index b574794c..367d9fce 100644 --- a/schedule/src/Decoders.elm +++ b/schedule/src/Decoders.elm @@ -46,8 +46,6 @@ speakerDecoder = |> 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 @@ -82,6 +80,7 @@ eventInstanceDecoder = |> required "url" string |> required "event_slug" string |> required "event_type" string + |> required "event_track" string |> required "bg-color" string |> required "fg-color" string |> required "from" dateDecoder @@ -111,6 +110,13 @@ eventTypeDecoder = |> required "light_text" bool +eventTrackDecoder : Decoder FilterType +eventTrackDecoder = + decode TrackFilter + |> required "name" string + |> required "slug" string + + initDataDecoder : Decoder (Flags -> Filter -> Location -> Route -> Bool -> Model) initDataDecoder = decode Model @@ -119,4 +125,5 @@ initDataDecoder = |> required "event_instances" (list eventInstanceDecoder) |> required "event_locations" (list eventLocationDecoder) |> required "event_types" (list eventTypeDecoder) + |> required "event_tracks" (list eventTrackDecoder) |> required "speakers" (list speakerDecoder) diff --git a/schedule/src/Main.elm b/schedule/src/Main.elm index 7b03faeb..b165e2f0 100644 --- a/schedule/src/Main.elm +++ b/schedule/src/Main.elm @@ -34,10 +34,10 @@ init flags location = parseLocation location emptyFilter = - Filter [] [] [] + Filter [] [] [] [] model = - Model [] [] [] [] [] [] flags emptyFilter location currentRoute False + Model [] [] [] [] [] [] [] flags emptyFilter location currentRoute False in model ! [ sendInitMessage flags.camp_slug flags.websocket_server ] diff --git a/schedule/src/Models.elm b/schedule/src/Models.elm index 9f60f394..305c57fa 100644 --- a/schedule/src/Models.elm +++ b/schedule/src/Models.elm @@ -49,6 +49,7 @@ type alias Model = , eventInstances : List EventInstance , eventLocations : List FilterType , eventTypes : List FilterType + , eventTracks : List FilterType , speakers : List Speaker , flags : Flags , filter : Filter @@ -69,8 +70,6 @@ type alias Speaker = { name : String , slug : SpeakerSlug , biography : String - , largePictureUrl : Maybe String - , smallPictureUrl : Maybe String } @@ -81,6 +80,7 @@ type alias EventInstance = , url : String , eventSlug : EventSlug , eventType : String + , eventTrack : String , backgroundColor : String , forgroundColor : String , from : Date @@ -142,11 +142,13 @@ type FilterType = TypeFilter FilterName FilterSlug TypeColor TypeLightText | LocationFilter FilterName FilterSlug LocationIcon | VideoFilter FilterName FilterSlug + | TrackFilter FilterName FilterSlug type alias Filter = { eventTypes : List FilterType , eventLocations : List FilterType + , eventTracks : List FilterType , videoRecording : List FilterType } @@ -162,6 +164,9 @@ unpackFilterType filter = VideoFilter name slug -> ( name, slug ) + TrackFilter name slug -> + ( name, slug ) + getSlugFromFilterType filter = let diff --git a/schedule/src/Update.elm b/schedule/src/Update.elm index 8d2ad964..720cea93 100644 --- a/schedule/src/Update.elm +++ b/schedule/src/Update.elm @@ -96,6 +96,19 @@ update msg model = videoRecording :: model.filter.videoRecording } + TrackFilter name slug -> + let + eventTrack = + TrackFilter name slug + in + { currentFilter + | eventTracks = + if List.member eventTrack model.filter.eventTracks then + List.filter (\x -> x /= eventTrack) model.filter.videoRecording + else + eventTrack :: model.filter.eventTracks + } + query = filterToQuery newFilter diff --git a/schedule/src/Views/DayView.elm b/schedule/src/Views/DayView.elm index a93f3af2..d421c583 100644 --- a/schedule/src/Views/DayView.elm +++ b/schedule/src/Views/DayView.elm @@ -69,7 +69,7 @@ locationColumns eventInstances eventLocations offset minutes = , ( "justify-content", "space-around" ) ] , classList - [ ( "col-sm-11", True ) + [ ( "col-xs-11", True ) ] ] (List.map (\location -> locationColumn columnWidth eventInstances offset minutes location) eventLocations) @@ -269,7 +269,7 @@ gutter : List Date -> Html Msg gutter hours = div [ classList - [ ( "col-sm-1", True ) + [ ( "col-xs-1", True ) , ( "day-view-gutter", True ) ] ] diff --git a/schedule/src/Views/EventDetail.elm b/schedule/src/Views/EventDetail.elm index 93e35451..97bb69b0 100644 --- a/schedule/src/Views/EventDetail.elm +++ b/schedule/src/Views/EventDetail.elm @@ -123,14 +123,13 @@ eventDetailSidebar event model = ] (videoRecordingLink ++ [ speakerSidebar speakers - , eventMetaDataSidebar event - , eventInstancesSidebar eventInstances + , eventMetaDataSidebar event eventInstances model ] ) -eventMetaDataSidebar : Event -> Html Msg -eventMetaDataSidebar event = +eventMetaDataSidebar : Event -> List EventInstance -> Model -> Html Msg +eventMetaDataSidebar event eventInstances model = let ( showVideoRecoring, videoRecording ) = case event.videoState of @@ -142,10 +141,23 @@ eventMetaDataSidebar event = _ -> ( False, "" ) + + eventInstanceMetaData = + case eventInstances of + [ instance ] -> + eventInstanceItem instance model + + instances -> + [ h4 [] + [ text "Multiple occurences:" ] + , ul + [] + (List.map (\ei -> li [] <| eventInstanceItem ei model) instances) + ] in div [] - [ h4 [] [ text "Metadata" ] - , ul [] + ([ h4 [] [ text "Metadata" ] + , ul [] ([ li [] [ strong [] [ text "Type: " ], text event.eventType ] ] ++ (case showVideoRecoring of @@ -156,7 +168,44 @@ eventMetaDataSidebar event = [] ) ) + ] + ++ eventInstanceMetaData + ) + + +eventInstanceItem : EventInstance -> Model -> List (Html Msg) +eventInstanceItem eventInstance model = + let + toFormat = + if Date.day eventInstance.from == Date.day eventInstance.to then + "HH:mm" + else + "E HH:mm" + + ( locationName, _ ) = + model.eventLocations + |> List.map unpackFilterType + |> List.filter + (\( _, locationSlug ) -> + locationSlug == eventInstance.location + ) + |> List.head + |> Maybe.withDefault ( "Unknown", "" ) + in + [ p [] + [ strong [] [ text "When: " ] + , text + ((Date.Extra.toFormattedString "E HH:mm" eventInstance.from) + ++ " to " + ++ (Date.Extra.toFormattedString toFormat eventInstance.to) + ) ] + , p [] + [ strong [] [ text "Where: " ] + , text <| locationName ++ " " + , i [ classList [ ( "fa", True ), ( "fa-" ++ eventInstance.locationIcon, True ) ] ] [] + ] + ] speakerSidebar : List Speaker -> Html Msg @@ -175,32 +224,3 @@ 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) - ) - ] diff --git a/schedule/src/Views/FilterView.elm b/schedule/src/Views/FilterView.elm index 8a641868..4ba483d3 100644 --- a/schedule/src/Views/FilterView.elm +++ b/schedule/src/Views/FilterView.elm @@ -37,6 +37,9 @@ applyFilters day model = locations = slugs model.eventLocations model.filter.eventLocations + tracks = + slugs model.eventTracks model.filter.eventTracks + videoFilters = slugs videoRecordingFilters model.filter.videoRecording @@ -47,6 +50,7 @@ applyFilters day model = && (Date.Extra.equalBy Date.Extra.Day eventInstance.from day.date) && List.member eventInstance.location locations && List.member eventInstance.eventType types + && List.member eventInstance.eventTrack tracks && List.member eventInstance.videoState videoFilters ) model.eventInstances @@ -77,6 +81,12 @@ filterSidebar model = model.filter.eventLocations model.eventInstances .location + , filterView + "Track" + model.eventTracks + model.filter.eventTracks + model.eventInstances + .eventTrack , filterView "Video" videoRecordingFilters @@ -214,10 +224,10 @@ filterChoiceView filter currentFilters eventInstances slugLike = "film" "to-be-recorded" -> - "video-camera" + "video" "not-to-be-recorded" -> - "ban" + "video-slash" _ -> "" @@ -309,11 +319,15 @@ parseFilterFromQuery query model = locations = getFilter "location" model.eventLocations query + tracks = + getFilter "tracks" model.eventTracks query + videoFilters = getFilter "video" videoRecordingFilters query in { eventTypes = types , eventLocations = locations + , eventTracks = tracks , videoRecording = videoFilters } diff --git a/schedule/src/Views/ScheduleOverview.elm b/schedule/src/Views/ScheduleOverview.elm index 8e1f7fe9..95e533f1 100644 --- a/schedule/src/Views/ScheduleOverview.elm +++ b/schedule/src/Views/ScheduleOverview.elm @@ -80,25 +80,25 @@ dayEventInstanceIcons eventInstance = case eventInstance.videoState of "has-recording" -> [ i - [ classList [ ( "fa", True ), ( "fa-film", True ), ( "pull-right", True ) ] ] + [ classList [ ( "fa", True ), ( "fa-film", True ), ( "pull-right", True ), ( "fa-fw", True ) ] ] [] ] "to-be-recorded" -> [ i - [ classList [ ( "fa", True ), ( "fa-video-camera", True ), ( "pull-right", True ) ] ] + [ classList [ ( "fa", True ), ( "fa-video", True ), ( "pull-right", True ), ( "fa-fw", True ) ] ] [] ] "not-to-be-recorded" -> [ i - [ classList [ ( "fa", True ), ( "fa-ban", True ), ( "pull-right", True ) ] ] + [ classList [ ( "fa", True ), ( "fa-video-slash", True ), ( "pull-right", True ), ( "fa-fw", True ) ] ] [] ] _ -> [] in - [ i [ classList [ ( "fa", True ), ( "fa-" ++ eventInstance.locationIcon, True ), ( "pull-right", True ) ] ] [] + [ i [ classList [ ( "fa", True ), ( "fa-" ++ eventInstance.locationIcon, True ), ( "pull-right", True ), ( "fa-fw", True ) ] ] [] ] ++ videoIcon diff --git a/schedule/src/Views/SpeakerDetail.elm b/schedule/src/Views/SpeakerDetail.elm index 4c13f2b7..c54248d9 100644 --- a/schedule/src/Views/SpeakerDetail.elm +++ b/schedule/src/Views/SpeakerDetail.elm @@ -22,33 +22,18 @@ speakerDetailView speakerSlug model = 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 ) ] ] + [ 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 - ) + , h3 [] [ text speaker.name ] + , div [] [ Markdown.toHtml [] speaker.biography ] + , speakerEvents speaker model + ] Nothing -> div [] [ text "Unknown speaker..." ] diff --git a/scripts/schemagif.sh b/scripts/schemagif.sh new file mode 100755 index 00000000..41407832 --- /dev/null +++ b/scripts/schemagif.sh @@ -0,0 +1,69 @@ +#!/bin/sh +################################# +# Loop over migrations in the +# BornHack website project, apply +# one by one, and run +# postgresql_autodoc for each. +# +# Use the generated .dot files +# to generate PNGs and watermark +# the PNG with the migration name. +# +# Finally use $whatever to combine +# all the PNGs to an animation and +# marvel at the ingenuity of Man. +# +# This scripts makes a million +# assumptions about the local env. +# and installed packages. Enjoy! +# +# /Tykling, April 2018 +################################# +#set -x + +# warn the user +read -p "WARNING: This scripts deletes and recreates the local pg database named bornhackdb several times. Continue? " + +# wipe database +sudo su postgres -c "dropdb bornhackdb; createdb -O bornhack bornhackdb" + +# run migrate with --fake to get list of migrations +MIGRATIONS=$(python manage.py migrate --fake | grep FAKED | cut -d " " -f 4 | cut -d "." -f 1-2) + +# wipe database again +sudo su postgres -c "dropdb bornhackdb; createdb -O bornhack bornhackdb" + +# create output folder +sudo rm -rf postgres_autodoc +mkdir postgres_autodoc +sudo chown postgres:postgres postgres_autodoc + +# loop over migrations +COUNTER=0 +for MIGRATION in $MIGRATIONS; do + COUNTER=$(( $COUNTER + 1 )) + ALFACOUNTER=$(printf "%04d" $COUNTER) + + echo "processing migration #${COUNTER}: $MIG" + APP=$(echo $MIGRATION | cut -d "." -f 1) + MIG=$(echo $MIGRATION | cut -d "." -f 2) + + echo "--- running migration: APP: $APP MIGRATION: $MIG ..." + python manage.py migrate --no-input $APP $MIG + + echo "--- running postgresql_autodoc and dot..." + cd postgres_autodoc + sudo su postgres -c "mkdir ${ALFACOUNTER}-$MIGRATION" + cd "${ALFACOUNTER}-${MIGRATION}" + # run postgresql_autodoc + sudo su postgres -c "postgresql_autodoc -d bornhackdb" + # create PNG from .dot file + sudo su postgres -c "dot -Tpng bornhackdb.dot -o bornhackdb.png" + # create watermark image with migration name as white on black text + sudo su postgres -c "convert -background none -undercolor black -fill white -font DejaVu-Sans-Mono-Bold -size 5316x4260 -pointsize 72 -gravity SouthEast label:${ALFACOUNTER}-${MIGRATION} background.png" + # combine the images + sudo su postgres -c "composite -gravity center bornhackdb.png background.png final.png" + cd .. + cd .. +done + diff --git a/src/backoffice/mixins.py b/src/backoffice/mixins.py new file mode 100644 index 00000000..17361b74 --- /dev/null +++ b/src/backoffice/mixins.py @@ -0,0 +1,30 @@ +from utils.mixins import RaisePermissionRequiredMixin + + +class OrgaTeamPermissionMixin(RaisePermissionRequiredMixin): + """ + Permission mixin for views used by Orga Team + """ + permission_required = ("camps.backoffice_permission", "camps.orgateam_permission") + + +class EconomyTeamPermissionMixin(RaisePermissionRequiredMixin): + """ + Permission mixin for views used by Economy Team + """ + permission_required = ("camps.backoffice_permission", "camps.economyteam_permission") + + +class InfoTeamPermissionMixin(RaisePermissionRequiredMixin): + """ + Permission mixin for views used by Info Team/InfoDesk + """ + permission_required = ("camps.backoffice_permission", "camps.infoteam_permission") + + +class ContentTeamPermissionMixin(RaisePermissionRequiredMixin): + """ + Permission mixin for views used by Content Team + """ + permission_required = ("camps.backoffice_permission", "program.contentteam_permission") + diff --git a/src/backoffice/templates/approve_public_credit_names.html b/src/backoffice/templates/approve_public_credit_names.html new file mode 100644 index 00000000..0ed6beb8 --- /dev/null +++ b/src/backoffice/templates/approve_public_credit_names.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Approve Public Credit Names

+
+ Use this view to approve users public credit names. +
+
+
+
+ + + + + + + + + + + {% for profile in profiles %} + + + + + + + {% endfor %} + +
UsernameEmailPublic Credit NameActions
{{ profile.user.username }}{{ profile.user.email }}{{ profile.public_credit_name }} + Open In Admin +
+
+{% endblock content %} + diff --git a/src/backoffice/templates/backoffice_index.html b/src/backoffice/templates/backoffice_index.html deleted file mode 100644 index 774edacc..00000000 --- a/src/backoffice/templates/backoffice_index.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'base.html' %} -{% load commonmark %} -{% load static from staticfiles %} -{% load imageutils %} -{% block content %} -
-
- Please select your desired action below. -
-
-
-

-

-

-
-{% endblock content %} - - diff --git a/src/backoffice/templates/badge_handout.html b/src/backoffice/templates/badge_handout.html new file mode 100644 index 00000000..722f9795 --- /dev/null +++ b/src/backoffice/templates/badge_handout.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Hand Out Badges

+
+ Use this view to hand out badges to participants. Use the search field to search for username, email, products, order ID, ticket UUID, etc. To check in participants go to the Ticket Checkin view instead. To hand out merchandise and other products go to the Hand Out Products view instead. +
+
+ This table shows all (Shop|Discount|Sponsor)Tickets which are badge_handed_out=False. Tickets must be checked in before they are shown in this list. +
+
+
+
+ + + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + + {% endfor %} + +
Ticket UUIDTicket TypeTicket Order IDOrder UserOrder EmailTicket NameTicket EmailProduct
{{ ticket.uuid }}{{ ticket.shortname }}{{ ticket.order.id }}{{ ticket.order.user }}{{ ticket.order.user.email }}{{ ticket.name }}{{ ticket.email }}{{ ticket.product }}
+
+{% endblock content %} + diff --git a/src/backoffice/templates/camp_select.html b/src/backoffice/templates/camp_select.html new file mode 100644 index 00000000..02f0b88d --- /dev/null +++ b/src/backoffice/templates/camp_select.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block content %} + +
+

BornHack Backoffice Camp Picker

+
+ +
+

+

+ {% for camp in camp_list %} + +

{{ camp.title }}

+

Manage {{ camp.title }}

+
+ {% endfor %} +
+
+ +{% endblock content %} + diff --git a/src/backoffice/templates/expense_detail_backoffice.html b/src/backoffice/templates/expense_detail_backoffice.html new file mode 100644 index 00000000..b0206c9b --- /dev/null +++ b/src/backoffice/templates/expense_detail_backoffice.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Manage Expense

+ +{% include 'includes/expense_detail_panel.html' %} + +{% if expense.approved == None %} +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve Expense" button_type="submit" button_class="btn-success" name="approve" %} + {% bootstrap_button " Reject Expense" button_type="submit" button_class="btn-danger" name="reject" %} + Cancel +
+{% endif %} + +
+ Back to Expense List + +{% endblock content %} + diff --git a/src/backoffice/templates/expense_list_backoffice.html b/src/backoffice/templates/expense_list_backoffice.html new file mode 100644 index 00000000..cd90805d --- /dev/null +++ b/src/backoffice/templates/expense_list_backoffice.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} +

Manage Expenses for {{ camp.title }}

+ +{% if unapproved_expenses %} +
+ This table shows unapproved expenses for {{ camp.title }}. +
+ +{% include 'includes/expense_list_panel.html' with expense_list=unapproved_expenses %} + +
+{% endif %} + +
+ This table shows all approved expenses for {{ camp.title }}. +
+ +{% include 'includes/expense_list_panel.html' %} + +{% endblock content %} diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html new file mode 100644 index 00000000..792c4bc1 --- /dev/null +++ b/src/backoffice/templates/index.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block content %} + +
+

{{ camp.title }} Backoffice

+
+ Welcome to the promised land! Please select your desired action below: +
+
+ +
+

+

+ {% if perms.camps.infoteam_permission %} +

Info Team

+ +

Hand Out Products

+

Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.

+
+ +

Check-In Tickets

+

Use this view to check-in tickets when participants arrive.

+
+ +

Hand Out Badges

+

Use this view to mark badges as handed out.

+
+ {% endif %} + + {% if perms.camps.contentteam_permission %} +

Content Team

+ +

Manage Proposals

+

Use this view to manage SpeakerProposals and EventProposals

+
+ {% endif %} + + {% if perms.camps.orgateam_permission %} +

Orga Team

+ +

Approve Public Credit Names

+

Use this view to check and approve users Public Credit Names

+
+ +

Merchandise Orders

+

Use this view to look at Merchandise Orders

+
+ +

Merchandise To Order

+

Use this view to generate a list of merchandise that needs to be ordered

+
+ +

Village Orders

+

Use this view to look at Village category OrderProductRelations

+
+ +

Village Gear To Order

+

Use this view to generate a list of village gear that needs to be ordered

+
+ {% endif %} + + {% if perms.camps.economyteam_permission %} +

Economy Team

+ +

Expenses

+

Use this view to see and approve/reject expenses.

+
+ +

Reimbursements

+

Use this view to view and create reimbursements for approved expenses.

+
+ +

Revenues

+

Use this view to see and approve/reject revenues.

+
+ {% endif %} +
+
+ +{% endblock content %} + diff --git a/src/backoffice/templates/manage_eventproposal.html b/src/backoffice/templates/manage_eventproposal.html new file mode 100644 index 00000000..4fcb51b7 --- /dev/null +++ b/src/backoffice/templates/manage_eventproposal.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Manage {{ form.instance.event_type.name }} Proposal

+{% include 'includes/eventproposal_detail.html' with camp=camp %} +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve" button_type="submit" button_class="btn-success" name="approve" %} + {% bootstrap_button " Reject" button_type="submit" button_class="btn-danger" name="reject" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/manage_proposals.html b/src/backoffice/templates/manage_proposals.html new file mode 100644 index 00000000..40a9eaf0 --- /dev/null +++ b/src/backoffice/templates/manage_proposals.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% load bornhack %} + +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

BackOffice - Manage Speaker+EventProposals

+
+ The Content team can approve or reject pending SpeakerProposals and EventProposals from this page. +
+
+
+
+

SpeakerProposals

+ {% if not speakerproposals %} +

No pending SpeakerProposals found

+ {% else %} + + + + + + + + + + + + + {% for proposal in speakerproposals %} + + + + + + + + + {% endfor %} + +
NameEmailTicket?Speaker?Submitting UserAction
{{ proposal.name }}{{ proposal.email }}{{ proposal.needs_oneday_ticket|truefalseicon }}{{ proposal.event|truefalseicon }}{{ proposal.user }} Manage
+ {% endif %} + +

EventProposals

+ {% if not eventproposals %} +

No pending SpeakerProposals found

+ {% else %} + + + + + + + + + + + + + + {% for proposal in eventproposals %} + + + + + + + + + + {% endfor %} + +
TitleTrackTypeSpeakersEvent?Submitting UserAction
{{ proposal.title }}{{ proposal.track }} {{ proposal.event_type }}{% for speaker in proposal.speakers.all %} {% endfor %}{{ proposal.speaker|truefalseicon }}{{ proposal.user }} Manage
+ {% endif %} +
+{% endblock content %} + diff --git a/src/backoffice/templates/manage_speakerproposal.html b/src/backoffice/templates/manage_speakerproposal.html new file mode 100644 index 00000000..ab545463 --- /dev/null +++ b/src/backoffice/templates/manage_speakerproposal.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Manage Speaker Proposal

+{% include 'includes/speakerproposal_detail.html' with camp=camp %} +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve" button_type="submit" button_class="btn-success" name="approve" %} + {% bootstrap_button " Reject" button_type="submit" button_class="btn-danger" name="reject" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/merchandise_to_order.html b/src/backoffice/templates/merchandise_to_order.html new file mode 100644 index 00000000..edcb9aed --- /dev/null +++ b/src/backoffice/templates/merchandise_to_order.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Merchandise To Order

+
+ This is a list of merchandise to order from our supplier +
+
+ This table shows all different merchandise that needs to be ordered +
+
+
+
+ + + + + + + + + {% for key, val in merchandise.items %} + + + + + {% endfor %} + +
Merchandise TypeQuantity
{{ key }}{{ val }}
+
+ - {% endblock content %} - - diff --git a/src/backoffice/templates/orders_village.html b/src/backoffice/templates/orders_village.html new file mode 100644 index 00000000..358c7659 --- /dev/null +++ b/src/backoffice/templates/orders_village.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Village Orders

+
+ Use this view to look at village orders.
+
+ This table shows all OrderProductRelations which are in the Village category (not including handed out, unpaid, cancelled and refunded orders). The table is initally sorted by order ID but the sorting can be changed by clicking the column headlines (if javascript is enabled). +
+
+
+
+ + + + + + + + + + + + + {% for productrel in orderproductrelation_list %} + + + + + + + + + {% endfor %} + +
OrderUserEmailOPR IdProductQuantity
Order #{{ productrel.order.id }}{{ productrel.order.user }}{{ productrel.order.user.email }}{{ productrel.id }}{{ productrel.product.name }}{{ productrel.quantity }}
+
+{% endblock content %} diff --git a/src/backoffice/templates/product_handout.html b/src/backoffice/templates/product_handout.html new file mode 100644 index 00000000..677ff974 --- /dev/null +++ b/src/backoffice/templates/product_handout.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Hand Out Products

+
+ Use this view to hand out products to participants. Use the search field to search for username, email, products, order ID etc. To check in participants go to the Ticket Checkin view instead. To hand out badges go to the Badge Handout view instead. +
+
+ This table shows all OrderProductRelations which are handed_out=False (not including unpaid, cancelled and refunded orders). The table is initally sorted by order ID but the sorting can be changed by clicking the column headlines (if javascript is enabled). +
+
+
+
+ + + + + + + + + + + + + {% for productrel in orderproductrelation_list %} + + + + + + + + + {% endfor %} + +
OrderUserEmailOPR IdProductQuantity
Order #{{ productrel.order.id }}{{ productrel.order.user }}{{ productrel.order.user.email }}{{ productrel.id }}{{ productrel.product.name }}{{ productrel.quantity }}
+
+{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_create.html b/src/backoffice/templates/reimbursement_create.html new file mode 100644 index 00000000..056493f1 --- /dev/null +++ b/src/backoffice/templates/reimbursement_create.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Create Reimbursement for User {{ reimbursement_user }}

+ +
+
+

The following approved expenses will be covered by this reimbursement:

+
+
+ + + + + + + + + + + {% for expense in expenses %} + + + + + + + {% endfor %} + +
DescriptionAmountInvoiceResponsible Team
{{ expense.description }}{{ expense.amount }}{{ expense.invoice }}{{ expense.responsible_team }} Team
+
+
+ +

The total amount for this reimbursement will be {{ total_amount.amount__sum }} DKK

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve" button_type="submit" button_class="btn-success" name="approve" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_create_userselect.html b/src/backoffice/templates/reimbursement_create_userselect.html new file mode 100644 index 00000000..429a5315 --- /dev/null +++ b/src/backoffice/templates/reimbursement_create_userselect.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block content %} + +
+

Create Reimbursement - Select User

+
+ Start by selecting the user for whom you wish to create a reimbursement below: +
+
+ +
+
+ {% for user in object_list %} + +

{{ user.username }}

+

Create a reimbursement for user {{ user.username }}

+
+ {% endfor %} +
+
+ +{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_delete.html b/src/backoffice/templates/reimbursement_delete.html new file mode 100644 index 00000000..19dcbbcb --- /dev/null +++ b/src/backoffice/templates/reimbursement_delete.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Delete Reimbursement for User {{ reimbursement_user }}

+ +

The total amount for this reimbursement is {{ reimbursement.amount }} DKK

+ +

Really delete this reimbursement?

+ +
+ {% csrf_token %} + {% bootstrap_button " Yes, Delete it" button_type="submit" button_class="btn-danger" name="Delete" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_detail_backoffice.html b/src/backoffice/templates/reimbursement_detail_backoffice.html new file mode 100644 index 00000000..6a649f6c --- /dev/null +++ b/src/backoffice/templates/reimbursement_detail_backoffice.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Reimbursement Details

+ +{% include 'includes/reimbursement_detail_panel.html' %} + + Update + Delete + Back to reimbursement list + +{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_form.html b/src/backoffice/templates/reimbursement_form.html new file mode 100644 index 00000000..43764be4 --- /dev/null +++ b/src/backoffice/templates/reimbursement_form.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Update Reimbursement for User {{ reimbursement_user }}

+ +

The total amount for this reimbursement is {{ reimbursement.amount }} DKK

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Update" button_type="submit" button_class="btn-success" name="Update" %} + Cancel +
+{% endblock content %} + diff --git a/src/backoffice/templates/reimbursement_list_backoffice.html b/src/backoffice/templates/reimbursement_list_backoffice.html new file mode 100644 index 00000000..f0519888 --- /dev/null +++ b/src/backoffice/templates/reimbursement_list_backoffice.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} +

Reimbursements for {{ camp.title }}

+ +{% include 'includes/reimbursement_list_panel.html' %} + + Create New Reimbursement +{% endblock content %} diff --git a/src/backoffice/templates/revenue_detail_backoffice.html b/src/backoffice/templates/revenue_detail_backoffice.html new file mode 100644 index 00000000..32a0413d --- /dev/null +++ b/src/backoffice/templates/revenue_detail_backoffice.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Manage Revenue

+ +{% include 'includes/revenue_detail_panel.html' %} + +{% if revenue.approved == None %} +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Approve Revenue" button_type="submit" button_class="btn-success" name="approve" %} + {% bootstrap_button " Reject Revenue" button_type="submit" button_class="btn-danger" name="reject" %} + Cancel +
+{% endif %} +
+ Back to Revenue List + +{% endblock content %} + diff --git a/src/backoffice/templates/revenue_list_backoffice.html b/src/backoffice/templates/revenue_list_backoffice.html new file mode 100644 index 00000000..cd18fbbe --- /dev/null +++ b/src/backoffice/templates/revenue_list_backoffice.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block extra_head %} + + +{% endblock extra_head %} + +{% block content %} +

Manage Revenues for {{ camp.title }}

+ +{% if unapproved_revenues %} +
+ This table shows unapproved revenues for {{ camp.title }}. +
+ +{% include 'includes/revenue_list_panel.html' with revenue_list=unapproved_revenues %} + +
+{% endif %} + +
+ This table shows all approved revenues for {{ camp.title }}. +
+ +{% include 'includes/revenue_list_panel.html' %} + +{% endblock content %} diff --git a/src/backoffice/templates/ticket_checkin.html b/src/backoffice/templates/ticket_checkin.html new file mode 100644 index 00000000..b35181ef --- /dev/null +++ b/src/backoffice/templates/ticket_checkin.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Ticket Check-In

+
+ Use this view to check in participants. Use the search field to search for username, email, products, order ID, ticket UUID, etc. To hand out badges go to the Badge Handout view instead. To hand out other products go to the Hand Out Products view instead. +
+
+ This table shows all (Shop|Discount|Sponsor)Tickets which are checked_in=False. +
+
+
+
+ + + + + + + + + + + + + + + {% for ticket in tickets %} + + + + + + + + + + + {% endfor %} + +
Ticket UUIDTicket TypeOrder IDOrder UserOrder EmailTicket NameTicket EmailProduct
{{ ticket.uuid }}{{ ticket.shortname }}{{ ticket.order.id }}{{ ticket.order.user }}{{ ticket.order.user.email }}{{ ticket.name }}{{ ticket.email }}{{ ticket.product }}
+
+{% endblock content %} + diff --git a/src/backoffice/templates/village_to_order.html b/src/backoffice/templates/village_to_order.html new file mode 100644 index 00000000..9f8099e5 --- /dev/null +++ b/src/backoffice/templates/village_to_order.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block extra_head %} + + +{% endblock extra_head %} +{% block content %} +
+

Village Gear To Order

+
+ This is a list of village gear to order from our supplier +
+
+ This table shows all different village stuff that needs to be ordered +
+
+
+
+ + + + + + + + + {% for key, val in village.items %} + + + + + {% endfor %} + +
TypeQuantity
{{ key }}{{ val }}
+
+{% endblock content %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 66d05351..ae7ea6e3 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -1,8 +1,69 @@ -from django.conf.urls import url +from django.urls import path, include from .views import * + +app_name = 'backoffice' + urlpatterns = [ - url(r'^$', BackofficeIndexView.as_view(), name='index'), - url(r'infodesk/$', InfodeskView.as_view(), name='infodesk_index'), + path('', BackofficeIndexView.as_view(), name='index'), + # infodesk + path('product_handout/', ProductHandoutView.as_view(), name='product_handout'), + path('badge_handout/', BadgeHandoutView.as_view(), name='badge_handout'), + path('ticket_checkin/', TicketCheckinView.as_view(), name='ticket_checkin'), + + # public names + path('public_credit_names/', ApproveNamesView.as_view(), name='public_credit_names'), + + # merchandise orders + path('merchandise_orders/', MerchandiseOrdersView.as_view(), name='merchandise_orders'), + path('merchandise_to_order/', MerchandiseToOrderView.as_view(), name='merchandise_to_order'), + + # village orders + path('village_orders/', VillageOrdersView.as_view(), name='village_orders'), + path('village_to_order/', VillageToOrderView.as_view(), name='village_to_order'), + + # manage proposals + path('manage_proposals/', include([ + path('', ManageProposalsView.as_view(), name='manage_proposals'), + path('speakers//', SpeakerProposalManageView.as_view(), name='speakerproposal_manage'), + path('events//', EventProposalManageView.as_view(), name='eventproposal_manage'), + ])), + + # economy + path('economy/', + include([ + # expenses + path('expenses/', + include([ + path('', ExpenseListView.as_view(), name='expense_list'), + path('/', ExpenseDetailView.as_view(), name='expense_detail'), + ]), + ), + + # revenues + path('revenues/', + include([ + path('', RevenueListView.as_view(), name='revenue_list'), + path('/', RevenueDetailView.as_view(), name='revenue_detail'), + ]), + ), + + # reimbursements + path('reimbursements/', + include([ + path('', ReimbursementListView.as_view(), name='reimbursement_list'), + path('/', + include([ + path('', ReimbursementDetailView.as_view(), name='reimbursement_detail'), + path('update/', ReimbursementUpdateView.as_view(), name='reimbursement_update'), + path('delete/', ReimbursementDeleteView.as_view(), name='reimbursement_delete'), + ]), + ), + path('create/', ReimbursementCreateUserSelectView.as_view(), name='reimbursement_create_userselect'), + path('create//', ReimbursementCreateView.as_view(), name='reimbursement_create'), + ]), + ), + ]), + ), ] diff --git a/src/backoffice/views.py b/src/backoffice/views.py index 0dbd2717..827391ba 100644 --- a/src/backoffice/views.py +++ b/src/backoffice/views.py @@ -1,26 +1,447 @@ -from django.views.generic import TemplateView, ListView -from django.shortcuts import redirect -from django.views import View +import logging, os +from itertools import chain + +from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin +from django.contrib.auth.models import User +from django.views.generic import TemplateView, ListView, DetailView +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.shortcuts import redirect, get_object_or_404 +from django.urls import reverse +from django.contrib import messages +from django.utils import timezone +from django.db.models import Sum from django.conf import settings -from django.utils.decorators import method_decorator -from django.http import HttpResponseForbidden -from shop.models import Order -import logging +from django.core.files import File + +from camps.mixins import CampViewMixin +from shop.models import OrderProductRelation +from tickets.models import ShopTicket, SponsorTicket, DiscountTicket +from profiles.models import Profile +from program.models import SpeakerProposal, EventProposal +from economy.models import Expense, Reimbursement, Revenue +from utils.mixins import RaisePermissionRequiredMixin +from teams.models import Team +from .mixins import * + logger = logging.getLogger("bornhack.%s" % __name__) -class StaffMemberRequiredMixin(object): +class BackofficeIndexView(CampViewMixin, RaisePermissionRequiredMixin, TemplateView): + """ + The Backoffice index view only requires camps.backoffice_permission so we use RaisePermissionRequiredMixin directly + """ + permission_required = ("camps.backoffice_permission") + template_name = "index.html" + + +class ProductHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView): + template_name = "product_handout.html" + + def get_queryset(self, **kwargs): + return OrderProductRelation.objects.filter( + handed_out=False, + order__paid=True, + order__refunded=False, + order__cancelled=False + ).order_by('order') + + +class BadgeHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView): + template_name = "badge_handout.html" + context_object_name = 'tickets' + + def get_queryset(self, **kwargs): + shoptickets = ShopTicket.objects.filter(badge_handed_out=False) + sponsortickets = SponsorTicket.objects.filter(badge_handed_out=False) + discounttickets = DiscountTicket.objects.filter(badge_handed_out=False) + return list(chain(shoptickets, sponsortickets, discounttickets)) + + +class TicketCheckinView(CampViewMixin, InfoTeamPermissionMixin, ListView): + template_name = "ticket_checkin.html" + context_object_name = 'tickets' + + def get_queryset(self, **kwargs): + shoptickets = ShopTicket.objects.filter(checked_in=False) + sponsortickets = SponsorTicket.objects.filter(checked_in=False) + discounttickets = DiscountTicket.objects.filter(checked_in=False) + return list(chain(shoptickets, sponsortickets, discounttickets)) + + +class ApproveNamesView(CampViewMixin, OrgaTeamPermissionMixin, ListView): + template_name = "approve_public_credit_names.html" + context_object_name = 'profiles' + + def get_queryset(self, **kwargs): + return Profile.objects.filter(public_credit_name_approved=False).exclude(public_credit_name='') + + +class ManageProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView): + """ + This view shows a list of pending SpeakerProposal and EventProposals. + """ + template_name = "manage_proposals.html" + context_object_name = 'speakerproposals' + + def get_queryset(self, **kwargs): + return SpeakerProposal.objects.filter( + camp=self.camp, + proposal_status=SpeakerProposal.PROPOSAL_PENDING + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['eventproposals'] = EventProposal.objects.filter( + track__camp=self.camp, + proposal_status=EventProposal.PROPOSAL_PENDING + ) + return context + + +class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateView): + """ + This class contains the shared logic between SpeakerProposalManageView and EventProposalManageView + """ + fields = [] + + def form_valid(self, form): + """ + We have two submit buttons in this form, Approve and Reject + """ + logger.debug(form.data) + if 'approve' in form.data: + # approve button was pressed + form.instance.mark_as_approved(self.request) + elif 'reject' in form.data: + # reject button was pressed + form.instance.mark_as_rejected(self.request) + else: + messages.error(self.request, "Unknown submit action") + return redirect(reverse('backoffice:manage_proposals', kwargs={'camp_slug': self.camp.slug})) + + +class SpeakerProposalManageView(ProposalManageBaseView): + """ + This view allows an admin to approve/reject SpeakerProposals + """ + model = SpeakerProposal + template_name = "manage_speakerproposal.html" + + +class EventProposalManageView(ProposalManageBaseView): + """ + This view allows an admin to approve/reject EventProposals + """ + model = EventProposal + template_name = "manage_eventproposal.html" + + +class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView): + template_name = "orders_merchandise.html" + + def get_queryset(self, **kwargs): + camp_prefix = 'BornHack {}'.format(timezone.now().year) + + return OrderProductRelation.objects.filter( + handed_out=False, + order__paid=True, + order__refunded=False, + order__cancelled=False, + product__category__name='Merchandise', + ).filter( + product__name__startswith=camp_prefix + ).order_by('order') + + +class MerchandiseToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView): + template_name = "merchandise_to_order.html" + + def get_context_data(self, **kwargs): + camp_prefix = 'BornHack {}'.format(timezone.now().year) + + order_relations = OrderProductRelation.objects.filter( + handed_out=False, + order__paid=True, + order__refunded=False, + order__cancelled=False, + product__category__name='Merchandise', + ).filter( + product__name__startswith=camp_prefix + ) + + merchandise_orders = {} + for relation in order_relations: + try: + quantity = merchandise_orders[relation.product.name] + relation.quantity + merchandise_orders[relation.product.name] = quantity + except KeyError: + merchandise_orders[relation.product.name] = relation.quantity + + context = super().get_context_data(**kwargs) + context['merchandise'] = merchandise_orders + return context + + +class VillageOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView): + template_name = "orders_village.html" + + def get_queryset(self, **kwargs): + camp_prefix = 'BornHack {}'.format(timezone.now().year) + + return OrderProductRelation.objects.filter( + handed_out=False, + order__paid=True, + order__refunded=False, + order__cancelled=False, + product__category__name='Villages', + ).filter( + product__name__startswith=camp_prefix + ).order_by('order') + + +class VillageToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView): + template_name = "village_to_order.html" + + def get_context_data(self, **kwargs): + camp_prefix = 'BornHack {}'.format(timezone.now().year) + + order_relations = OrderProductRelation.objects.filter( + handed_out=False, + order__paid=True, + order__refunded=False, + order__cancelled=False, + product__category__name='Villages', + ).filter( + product__name__startswith=camp_prefix + ) + + village_orders = {} + for relation in order_relations: + try: + quantity = village_orders[relation.product.name] + relation.quantity + village_orders[relation.product.name] = quantity + except KeyError: + village_orders[relation.product.name] = relation.quantity + + context = super().get_context_data(**kwargs) + context['village'] = village_orders + return context + + +################################ +########### EXPENSES ########### + +class ExpenseListView(CampViewMixin, EconomyTeamPermissionMixin, ListView): + model = Expense + template_name = 'expense_list_backoffice.html' + + def get_queryset(self, **kwargs): + """ + Exclude unapproved expenses, they are shown seperately + """ + queryset = super().get_queryset(**kwargs) + return queryset.exclude(approved__isnull=True) + + def get_context_data(self, **kwargs): + """ + Include unapproved expenses seperately + """ + context = super().get_context_data(**kwargs) + context['unapproved_expenses'] = Expense.objects.filter(camp=self.camp, approved__isnull=True) + return context + + +class ExpenseDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView): + model = Expense + template_name = 'expense_detail_backoffice.html' + fields = ['notes'] + + def form_valid(self, form): + """ + We have two submit buttons in this form, Approve and Reject + """ + expense = form.save() + if 'approve' in form.data: + # approve button was pressed + expense.approve(self.request) + elif 'reject' in form.data: + # reject button was pressed + expense.reject(self.request) + else: + messages.error(self.request, "Unknown submit action") + return redirect(reverse('backoffice:expense_list', kwargs={'camp_slug': self.camp.slug})) + + +###################################### +########### REIMBURSEMENTS ########### + +class ReimbursementListView(CampViewMixin, EconomyTeamPermissionMixin, ListView): + model = Reimbursement + template_name = 'reimbursement_list_backoffice.html' + + +class ReimbursementDetailView(CampViewMixin, EconomyTeamPermissionMixin, DetailView): + model = Reimbursement + template_name = 'reimbursement_detail_backoffice.html' + + +class ReimbursementCreateUserSelectView(CampViewMixin, EconomyTeamPermissionMixin, ListView): + template_name = 'reimbursement_create_userselect.html' + + def get_queryset(self): + queryset = User.objects.filter( + id__in=Expense.objects.filter( + camp=self.camp, + reimbursement__isnull=True, + paid_by_bornhack=False, + approved=True, + ).values_list('user', flat=True).distinct() + ) + return queryset + + +class ReimbursementCreateView(CampViewMixin, EconomyTeamPermissionMixin, CreateView): + model = Reimbursement + template_name = 'reimbursement_create.html' + fields = ['notes', 'paid'] + def dispatch(self, request, *args, **kwargs): - if not request.user.is_staff: - return HttpResponseForbidden() - return super().dispatch(request, *args, **kwargs) + """ Get the user from kwargs """ + print("inside dispatch() with method %s" % request.method) + self.reimbursement_user = get_object_or_404(User, pk=kwargs['user_id']) + + # get response now so we have self.camp available below + response = super().dispatch(request, *args, **kwargs) + + # return the response + return response + + def get(self, request, *args, **kwargs): + # does this user have any approved and un-reimbursed expenses? + if not self.reimbursement_user.expenses.filter(reimbursement__isnull=True, approved=True, paid_by_bornhack=False): + messages.error(request, "This user has no approved and unreimbursed expenses!") + return(redirect(reverse('backoffice:index', kwargs={'camp_slug': self.camp.slug}))) + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['expenses'] = Expense.objects.filter( + user=self.reimbursement_user, + approved=True, + reimbursement__isnull=True, + paid_by_bornhack=False, + ) + context['total_amount'] = context['expenses'].aggregate(Sum('amount')) + context['reimbursement_user'] = self.reimbursement_user + return context + + def form_valid(self, form): + """ + Set user and camp for the Reimbursement before saving + """ + # get the expenses for this user + expenses = Expense.objects.filter(user=self.reimbursement_user, approved=True, reimbursement__isnull=True, paid_by_bornhack=False) + if not expenses: + messages.error(self.request, "No expenses found") + return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug})) + + # get the Economy team for this camp + try: + economyteam = Team.objects.get(camp=self.camp, name=settings.ECONOMYTEAM_NAME) + except Team.DoesNotExist: + messages.error(self.request, "No economy team found") + return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug})) + + # create reimbursement in database + reimbursement = form.save(commit=False) + reimbursement.reimbursement_user = self.reimbursement_user + reimbursement.user = self.request.user + reimbursement.camp = self.camp + reimbursement.save() + + # add all expenses to reimbursement + for expense in expenses: + expense.reimbursement = reimbursement + expense.save() + + # create expense for this reimbursement + expense = Expense() + expense.camp=self.camp + expense.user=self.request.user + expense.amount=reimbursement.amount + expense.description="Payment of reimbursement %s to %s" % (reimbursement.pk, reimbursement.reimbursement_user) + expense.paid_by_bornhack=True + expense.responsible_team=economyteam + expense.approved=True + expense.reimbursement=reimbursement + expense.invoice.save("na.jpg", File(open(os.path.join(settings.DJANGO_BASE_PATH, "static_src/img/na.jpg"), "rb"))) + expense.save() + + messages.success(self.request, "Reimbursement %s has been created" % reimbursement.pk) + return redirect(reverse('backoffice:reimbursement_detail', kwargs={'camp_slug': self.camp.slug, 'pk': reimbursement.pk})) -class BackofficeIndexView(StaffMemberRequiredMixin, TemplateView): - template_name = "backoffice_index.html" +class ReimbursementUpdateView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView): + model = Reimbursement + template_name = 'reimbursement_form.html' + fields = ['notes', 'paid'] + + def get_success_url(self): + return reverse('backoffice:reimbursement_detail', kwargs={'camp_slug': self.camp.slug, 'pk': self.get_object().pk}) + +class ReimbursementDeleteView(CampViewMixin, EconomyTeamPermissionMixin, DeleteView): + model = Reimbursement + template_name = 'reimbursement_delete.html' + fields = ['notes', 'paid'] + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + if self.get_object().paid: + messages.error(request, "This reimbursement has already been paid so it cannot be deleted") + return redirect(reverse('backoffice:reimbursement_list', kwargs={'camp_slug': self.camp.slug})) + return response -class InfodeskView(StaffMemberRequiredMixin, ListView): - template_name = "infodesk.html" - queryset = Order.objects.filter(paid=True, cancelled=False, refunded=False, orderproductrelation__handed_out=False).distinct() +################################ +########### REVENUES ########### + +class RevenueListView(CampViewMixin, EconomyTeamPermissionMixin, ListView): + model = Revenue + template_name = 'revenue_list_backoffice.html' + + def get_queryset(self, **kwargs): + """ + Exclude unapproved revenues, they are shown seperately + """ + queryset = super().get_queryset(**kwargs) + return queryset.exclude(approved__isnull=True) + + def get_context_data(self, **kwargs): + """ + Include unapproved revenues seperately + """ + context = super().get_context_data(**kwargs) + context['unapproved_revenues'] = Revenue.objects.filter(camp=self.camp, approved__isnull=True) + return context + + +class RevenueDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView): + model = Revenue + template_name = 'revenue_detail_backoffice.html' + fields = ['notes'] + + def form_valid(self, form): + """ + We have two submit buttons in this form, Approve and Reject + """ + revenue = form.save() + if 'approve' in form.data: + # approve button was pressed + revenue.approve(self.request) + elif 'reject' in form.data: + # reject button was pressed + revenue.reject(self.request) + else: + messages.error(self.request, "Unknown submit action") + return redirect(reverse('backoffice:revenue_list', kwargs={'camp_slug': self.camp.slug})) diff --git a/src/bar/admin.py b/src/bar/admin.py index 337ee768..ee04ed0f 100644 --- a/src/bar/admin.py +++ b/src/bar/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin - from .models import ProductCategory, Product @@ -12,5 +11,3 @@ class ProductCategoryAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin): list_display = ['name', 'price', 'category', 'in_stock'] list_editable = ['in_stock'] - - diff --git a/src/bar/apps.py b/src/bar/apps.py index 7a6c111b..e24009a8 100644 --- a/src/bar/apps.py +++ b/src/bar/apps.py @@ -1,5 +1,3 @@ - - from django.apps import AppConfig diff --git a/src/bar/migrations/0003_auto_20180318_0906.py b/src/bar/migrations/0003_auto_20180318_0906.py new file mode 100644 index 00000000..1c600524 --- /dev/null +++ b/src/bar/migrations/0003_auto_20180318_0906.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 08:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bar', '0002_auto_20170916_2128'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='bar.ProductCategory'), + ), + migrations.AlterField( + model_name='productcategory', + name='camp', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='camps.Camp'), + ), + ] diff --git a/src/bar/models.py b/src/bar/models.py index 9c70a485..ce82f445 100644 --- a/src/bar/models.py +++ b/src/bar/models.py @@ -1,11 +1,10 @@ from django.db import models - from utils.models import CampRelatedModel class ProductCategory(CampRelatedModel): name = models.CharField(max_length=255) - camp = models.ForeignKey('camps.Camp') + camp = models.ForeignKey('camps.Camp', on_delete=models.PROTECT) def __str__(self): return self.name @@ -17,7 +16,11 @@ class ProductCategory(CampRelatedModel): class Product(models.Model): name = models.CharField(max_length=255) price = models.IntegerField() - category = models.ForeignKey(ProductCategory, related_name="products") + category = models.ForeignKey( + ProductCategory, + related_name="products", + on_delete=models.PROTECT + ) in_stock = models.BooleanField(default=True) class Meta: diff --git a/src/bar/views.py b/src/bar/views.py index b87306f4..a5e3acf8 100644 --- a/src/bar/views.py +++ b/src/bar/views.py @@ -1,9 +1,12 @@ -from .models import ProductCategory, Product - +from camps.mixins import CampViewMixin +from .models import ProductCategory from django.views.generic import ListView -class MenuView(ListView): +class MenuView(CampViewMixin, ListView): model = ProductCategory template_name = "bar_menu.html" context_object_name = "categories" + + def get_queryset(self): + return super().get_queryset().filter(camp=self.camp) diff --git a/src/bornhack/asgi.py b/src/bornhack/asgi.py index af900642..80b31c0c 100644 --- a/src/bornhack/asgi.py +++ b/src/bornhack/asgi.py @@ -1,7 +1,12 @@ +""" +ASGI entrypoint. Configures Django and then runs the application +defined in the ASGI_APPLICATION setting. +""" import os -from channels.asgi import get_channel_layer +import django +from channels.routing import get_default_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bornhack.settings") - -channel_layer = get_channel_layer() +django.setup() +application = get_default_application() diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index b86f7895..c62434b5 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -20,22 +20,17 @@ DEBUG={{ django_debug }} # the path to the wkhtmltopdf binary WKHTMLTOPDF_CMD="{{ wkhtmltopdf_path }}" -# set BACKEND to "asgiref.inmemory.ChannelLayer" and CONFIG to {} for local development -CHANNEL_LAYERS = { - "default": { - "BACKEND": "{{ django_channels_backend }}", - "ROUTING": "bornhack.routing.channel_routing", - "CONFIG": {{ django_channels_config }} - }, -} # start redirecting to the next camp instead of the previous camp after # this much of the time between the camps has passed -CAMP_REDIRECT_PERCENT=40 +CAMP_REDIRECT_PERCENT=25 ### changes below here are only needed for production # email settings +{% if not django_email_realworld | default(False) %} +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +{% endif %} EMAIL_HOST='{{ django_email_host }}' EMAIL_PORT={{ django_email_port }} EMAIL_HOST_USER='{{ django_email_user }}' @@ -61,6 +56,7 @@ COINIFY_CALLBACK_HOSTNAME='{{ coinify_callback_hostname | default('') }}' # leav # shop settings PDF_LETTERHEAD_FILENAME='{{ pdf_letterhead_filename }}' +BANKACCOUNT_BANK='{{ bank_name }}' BANKACCOUNT_IBAN='{{ iban }}' BANKACCOUNT_SWIFTBIC='{{ swiftbic }}' BANKACCOUNT_REG='{{ regno }}' @@ -75,13 +71,26 @@ SCHEDULE_EVENT_NOTIFICATION_MINUTES=10 # irc bot settings IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10 IRCBOT_NICK='{{ django_ircbot_nickname }}' +IRCBOT_CHANSERV_MASK='{{ django_ircbot_chanserv_mask }}' +IRCBOT_NICKSERV_MASK='{{ django_ircbot_nickserv_mask }}' IRCBOT_NICKSERV_PASSWORD='{{ django_ircbot_nickserv_password }}' +IRCBOT_NICKSERV_EMAIL='{{ django_ircbot_nickserv_email }}' +IRCBOT_NICKSERV_IDENTIFY_STRING="This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify \x02." IRCBOT_SERVER_HOSTNAME='{{ django_ircbot_server }}' IRCBOT_SERVER_PORT=6697 IRCBOT_SERVER_USETLS=True -IRCBOT_CHANNELS={ - 'default': '{{ django_ircbot_default_channel }}', - 'orga': '{{ django_ircbot_orga_channel }}', - 'public': '{{ django_ircbot_public_channel }}' +IRCBOT_PUBLIC_CHANNEL='{{ django_ircbot_public_channel }}' +IRCBOT_VOLUNTEER_CHANNEL='{{ django_ircbot_volunteer_channel }}' + +# set BACKEND to "channels.layers.InMemoryChannelLayer" and CONFIG to {} for local development +CHANNEL_LAYERS = { + "default": { + "BACKEND": "{{ django_channels_backend }}", + "CONFIG": {{ django_channels_config }} + }, } +ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}" +ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}" +ECONOMYTEAM_NAME = "Economy" + diff --git a/src/bornhack/environment_settings.py.dist.dev b/src/bornhack/environment_settings.py.dist.dev new file mode 100644 index 00000000..3009d2c9 --- /dev/null +++ b/src/bornhack/environment_settings.py.dist.dev @@ -0,0 +1,80 @@ +import os + +# MODIFY THIS! +# +# If you worry about loosing your local development database secrets, +# then change this for something less well-known. You can use lots of +# characters! +SECRET_KEY = "something-very-random" + +ALLOWED_HOSTS = "*" + +# MODIFY THIS! +# +# Database settings - modify to match your database configuration! +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "bornhack", + "USER": "bornhack", +# Comment back in if you are connecting via TCP +# "PASSWORD": "bornhack", +# "HOST": "localhost", + } +} +DEBUG = True +WKHTMLTOPDF_CMD = "wkhtmltopdf" +CHANNEL_LAYERS = {} + +ASGI_APPLICATION = "bornhack.routing.application" + +CAMP_REDIRECT_PERCENT = 40 +MEDIA_ROOT = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "media" +) + +# schedule settings +SCHEDULE_MIDNIGHT_OFFSET_HOURS = 9 +SCHEDULE_TIMESLOT_LENGTH_MINUTES = 30 +SCHEDULE_EVENT_NOTIFICATION_MINUTES = 10 + +PDF_LETTERHEAD_FILENAME = "bornhack-2017_test_letterhead.pdf" +PDF_ARCHIVE_PATH = os.path.join(MEDIA_ROOT, "pdf_archive") + +SENDFILE_ROOT = MEDIA_ROOT + "/protected" +SENDFILE_URL = "/protected" +SENDFILE_BACKEND = "sendfile.backends.development" + +IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS = 10 +IRCBOT_NICK = "humankillerbot" +IRCBOT_NICKSERV_PASSWORD = "" +IRCBOT_SERVER_HOSTNAME = "" +IRCBOT_SERVER_PORT = 6697 +IRCBOT_SERVER_USETLS = True +IRCBOT_CHANNELS = { + "default": "#my-bornhack-channel", + "orga": "#my-bornhack-channel", + "public": "#my-bornhack-channel", +} +IRCBOT_PUBLIC_CHANNEL = "#my-bornhack-channel" +IRCBOT_VOLUNTEER_CHANNEL = "#my-bornhack-channel" + +BANKACCOUNT_IBAN = "LOL" +BANKACCOUNT_SWIFTBIC = "lol" +BANKACCOUNT_REG = "lol" +BANKACCOUNT_ACCOUNT = "lol" +BANKACCOUNT_BANK = "lol" + +TIME_ZONE = "Europe/Copenhagen" + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +ARCHIVE_EMAIL = "archive@example.com" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + }, +} + +REIMBURSEMENT_MAIL = "reimbursement@example.com" diff --git a/src/bornhack/routing.py b/src/bornhack/routing.py index 7776c0e7..d426b02c 100644 --- a/src/bornhack/routing.py +++ b/src/bornhack/routing.py @@ -1,8 +1,14 @@ +from django.conf.urls import url + +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack from program.consumers import ScheduleConsumer -channel_routing = [ - ScheduleConsumer.as_route(path=r"^/schedule/"), -] - - +application = ProtocolTypeRouter({ + "websocket": AuthMiddlewareStack( + URLRouter([ + url(r"^schedule/", ScheduleConsumer) + ]) + ) +}) diff --git a/src/bornhack/schema.py b/src/bornhack/schema.py new file mode 100644 index 00000000..106de42f --- /dev/null +++ b/src/bornhack/schema.py @@ -0,0 +1,48 @@ +from graphene import relay, Schema, ObjectType + +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from camps.models import Camp + +from program.schema import ProgramQuery + + +class CampNode(DjangoObjectType): + class Meta: + model = Camp + interfaces = (relay.Node, ) + filter_fields = { + 'title': ['icontains', 'iexact'], + } + only_fields = ( + 'title', + 'slug', + 'tagline', + 'shortslug', + 'buildup', + 'camp', + 'teardown', + 'colour', + ) + + def resolve_buildup(self, info): + return [self.buildup.lower, self.buildup.upper] + + def resolve_camp(self, info): + return [self.camp.lower, self.camp.upper] + + def resolve_teardown(self, info): + return [self.teardown.lower, self.teardown.upper] + + +class CampQuery(object): + camp = relay.Node.Field(CampNode) + all_camps = DjangoFilterConnectionField(CampNode) + + +class Query(CampQuery, ProgramQuery, ObjectType): + pass + + +schema = Schema(query=Query) diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 1fa0e4f8..c7f6799c 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -1,18 +1,33 @@ import os +import wrapt +import django.views + from .environment_settings import * + def local_dir(entry): return os.path.join( os.path.dirname(os.path.dirname(__file__)), entry ) + +# We do this hacky monkeypatching to enable us to define a setup method +# on class based views for setting up variables without touching the dispatch +# method. +@wrapt.patch_function_wrapper(django.views.View, 'dispatch') +def monkey_patched_dispatch(wrapped, instance, args, kwargs): + if hasattr(instance, 'setup'): + instance.setup(*args, **kwargs) + return wrapped(*args, **kwargs) + + DJANGO_BASE_PATH = os.path.dirname(os.path.dirname(__file__)) WSGI_APPLICATION = 'bornhack.wsgi.application' +ASGI_APPLICATION = 'bornhack.routing.application' ROOT_URLCONF = 'bornhack.urls' - SITE_ID = 1 ADMINS = ( @@ -28,6 +43,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.sites', + 'graphene_django', 'channels', 'profiles', @@ -45,11 +61,18 @@ INSTALLED_APPS = [ 'tickets', 'bar', 'backoffice', + 'events', + 'rideshare', + 'tokens', + 'feedback', + 'economy', 'allauth', 'allauth.account', 'bootstrap3', 'django_extensions', + 'reversion', + 'betterforms', ] #MEDIA_URL = '/media/' @@ -168,4 +191,6 @@ LOGGING = { }, } - +GRAPHENE = { + 'SCHEMA': 'bornhack.schema.schema' +} diff --git a/src/bornhack/urls.py b/src/bornhack/urls.py index 5b4d6f0f..a864eef7 100644 --- a/src/bornhack/urls.py +++ b/src/bornhack/urls.py @@ -1,317 +1,219 @@ +from django.urls import include, path +from django.contrib import admin +from django.conf import settings +from django.views.decorators.csrf import csrf_exempt + from allauth.account.views import ( LoginView, LogoutView, ) -from django.conf import settings -from django.conf.urls import include, url -from django.contrib import admin +from graphene_django.views import GraphQLView + from camps.views import * +from feedback.views import FeedbackCreate from info.views import * from villages.views import * from program.views import * from sponsors.views import * -from teams.views import * from people.views import * -from tickets.views import ShopTicketListView from bar.views import MenuView urlpatterns = [ - url( - r'^profile/', + path( + 'profile/', include('profiles.urls', namespace='profiles') ), - url( - r'^tickets/', + path( + 'tickets/', include('tickets.urls', namespace='tickets') ), - url( - r'^shop/', + path( + 'shop/', include('shop.urls', namespace='shop') ), - url( - r'^news/', + path( + 'news/', include('news.urls', namespace='news') ), - url( - r'^contact/', + path( + 'contact/', TemplateView.as_view(template_name='contact.html'), name='contact' ), - url( - r'^conduct/', + path( + 'conduct/', TemplateView.as_view(template_name='coc.html'), name='conduct' ), - url( - r'^login/$', + path( + 'login/', LoginView.as_view(), name='account_login', ), - url( - r'^logout/$', + path( + 'logout/', LogoutView.as_view(), name='account_logout', ), - url( - r'^privacy-policy/$', + path( + 'privacy-policy/', TemplateView.as_view(template_name='legal/privacy_policy.html'), name='privacy-policy' ), - url( - r'^general-terms-and-conditions/$', + path( + 'general-terms-and-conditions/', TemplateView.as_view(template_name='legal/general_terms_and_conditions.html'), name='general-terms' ), - url(r'^accounts/', include('allauth.urls')), - url(r'^admin/', include(admin.site.urls)), + path('accounts/', include('allauth.urls')), + path('admin/', admin.site.urls), - url( - r'^camps/$', + # We don't need CSRF checks for the API + path('api/', csrf_exempt(GraphQLView.as_view(graphiql=True))), + + path( + 'camps/', CampListView.as_view(), name='camp_list' ), + path( + 'token/', + include('tokens.urls', namespace='tokens'), + ), + # camp redirect views here - url( - r'^$', + path( + '', CampRedirectView.as_view(), kwargs={'page': 'camp_detail'}, name='camp_detail_redirect', ), - url( - r'^program/$', + path( + 'program/', CampRedirectView.as_view(), kwargs={'page': 'schedule_index'}, name='schedule_index_redirect', ), - url( - r'^info/$', + path( + 'info/', CampRedirectView.as_view(), kwargs={'page': 'info'}, name='info_redirect', ), - url( - r'^sponsors/$', + path( + 'sponsors/', CampRedirectView.as_view(), kwargs={'page': 'sponsors'}, name='sponsors_redirect', ), - url( - r'^villages/$', + path( + 'villages/', CampRedirectView.as_view(), kwargs={'page': 'village_list'}, name='village_list_redirect', ), - url( - r'^people/$', + path( + 'people/', PeopleView.as_view(), name='people', ), - url( - r'^backoffice/', - include('backoffice.urls', namespace='backoffice') - ), - # camp specific urls below here - url( - r'(?P[-_\w+]+)/', include([ - url( - r'^$', + path( + '/', include([ + path( + '', CampDetailView.as_view(), name='camp_detail' ), - url( - r'^info/$', + path( + 'info/', CampInfoView.as_view(), name='info' ), - url( - r'^program/', include([ - 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" - ), - url( - r'^control/', ProgramControlCenter.as_view(), name="program_control_center" - ), - url( - r'^proposals/', include([ - url( - r'^$', - ProposalListView.as_view(), - name='proposal_list', - ), - url( - r'^speakers/', include([ - url( - r'^create/$', - SpeakerProposalCreateView.as_view(), - name='speakerproposal_create' - ), - url( - r'^(?P[a-f0-9-]+)/$', - SpeakerProposalDetailView.as_view(), - name='speakerproposal_detail' - ), - url( - r'^(?P[a-f0-9-]+)/edit/$', - SpeakerProposalUpdateView.as_view(), - name='speakerproposal_update' - ), - url( - r'^(?P[a-f0-9-]+)/submit/$', - SpeakerProposalSubmitView.as_view(), - name='speakerproposal_submit' - ), - url( - r'^(?P[a-f0-9-]+)/pictures/(?P[-_\w+]+)/$', - SpeakerProposalPictureView.as_view(), - name='speakerproposal_picture', - ), - ]) - ), - url( - r'^events/', include([ - url( - r'^create/$', - EventProposalCreateView.as_view(), - name='eventproposal_create' - ), - url( - r'^(?P[a-f0-9-]+)/$', - EventProposalDetailView.as_view(), - name='eventproposal_detail' - ), - url( - r'^(?P[a-f0-9-]+)/edit/$', - EventProposalUpdateView.as_view(), - name='eventproposal_update' - ), - url( - r'^(?P[a-f0-9-]+)/submit/$', - EventProposalSubmitView.as_view(), - name='eventproposal_submit' - ), - ]) - ), - ]) - ), - url( - r'^speakers/', include([ - url( - r'^$', - SpeakerListView.as_view(), - name='speaker_index' - ), - url( - r'^(?P[-_\w+]+)/$', - SpeakerDetailView.as_view(), - name='speaker_detail' - ), - url( - r'^(?P[-_\w+]+)/pictures/(?P[-_\w+]+)/$', - SpeakerPictureView.as_view(), - name='speaker_picture', - ), - ]), - ), - url( - r'^events/$', - EventListView.as_view(), - name='event_index' - ), - url( - r'^call-for-speakers/$', - CallForSpeakersView.as_view(), - name='call_for_speakers' - ), - url( - r'^calendar/', - ICSView.as_view(), - name='ics_calendar' - ), - # this has to be the last URL here - url( - r'^(?P[-_\w+]+)/$', - EventDetailView.as_view(), - name='event_detail' - ), - ]) + path( + 'program/', + include('program.urls', namespace='program'), ), - url( - r'^sponsors/call/$', - CallForSponsorsView.as_view(), - name='call-for-sponsors' - ), - url( - r'^sponsors/$', + path( + 'sponsors/', SponsorsView.as_view(), name='sponsors' ), - url( - r'^bar/menu$', + path( + 'bar/menu/', MenuView.as_view(), name='menu' ), - url( - r'^villages/', include([ - url( - r'^$', + path( + 'villages/', include([ + path( + '', VillageListView.as_view(), name='village_list' ), - url( - r'create/$', + path( + 'create/', VillageCreateView.as_view(), name='village_create' ), - url( - r'(?P[-_\w+]+)/delete/$', + path( + '/delete/', VillageDeleteView.as_view(), name='village_delete' ), - url( - r'(?P[-_\w+]+)/edit/$', + path( + '/edit/', VillageUpdateView.as_view(), name='village_update' ), # this has to be the last url in the list - url( - r'(?P[-_\w+]+)/$', + path( + '/', VillageDetailView.as_view(), name='village_detail' ), ]) ), - url( - r'^teams/', + path( + 'teams/', include('teams.urls', namespace='teams') ), + path( + 'rideshare/', + include('rideshare.urls', namespace='rideshare') + ), + path( + 'backoffice/', + include('backoffice.urls', namespace='backoffice') + ), + + path( + 'feedback/', + FeedbackCreate.as_view(), + name='feedback' + ), + + path( + 'economy/', + include('economy.urls', namespace='economy'), + ), ]) ) ] @@ -319,5 +221,6 @@ urlpatterns = [ if settings.DEBUG: import debug_toolbar urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), + path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns + diff --git a/src/camps/context_processors.py b/src/camps/context_processors.py index a1b290f7..bedd7ee2 100644 --- a/src/camps/context_processors.py +++ b/src/camps/context_processors.py @@ -1,6 +1,4 @@ -from django.conf import settings from .models import Camp -from django.utils import timezone def camp(request): diff --git a/src/camps/management/commands/createcamp.py b/src/camps/management/commands/createcamp.py index 4c55bfcc..77ea7af1 100644 --- a/src/camps/management/commands/createcamp.py +++ b/src/camps/management/commands/createcamp.py @@ -30,7 +30,7 @@ class Command(BaseCommand): files = [ 'sponsors/templates/{camp_slug}_sponsors.html', 'camps/templates/{camp_slug}_camp_detail.html', - 'program/templates/{camp_slug}_call_for_speakers.html' + 'program/templates/{camp_slug}_call_for_participation.html' ] # directories to create, relative to DJANGO_BASE_PATH @@ -68,3 +68,4 @@ class Command(BaseCommand): 'static_src/img/{camp_slug}/logo/{camp_slug}-logo-small.png'.format(camp_slug=camp_slug) ) ) + diff --git a/src/camps/migrations/0001_initial.py b/src/camps/migrations/0001_initial.py index 167f4b26..f715b265 100644 --- a/src/camps/migrations/0001_initial.py +++ b/src/camps/migrations/0001_initial.py @@ -35,7 +35,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('uuid', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)), ('date', models.DateField(help_text='What date?', verbose_name='Date')), - ('camp', models.ForeignKey(to='camps.Camp', help_text='Which camp does this day belong to.', verbose_name='Camp')), + ('camp', models.ForeignKey(on_delete=models.PROTECT, to='camps.Camp', help_text='Which camp does this day belong to.', verbose_name='Camp')), ], options={ 'verbose_name_plural': 'Days', @@ -51,8 +51,8 @@ class Migration(migrations.Migration): ('description', models.CharField(max_length=255, help_text='What this expense covers.', verbose_name='Description')), ('amount', models.DecimalField(max_digits=7, help_text='The amount of the expense.', verbose_name='Amount', decimal_places=2)), ('currency', models.CharField(max_length=3, choices=[('btc', 'BTC'), ('dkk', 'DKK'), ('eur', 'EUR'), ('sek', 'SEK')], help_text='What currency the amount is in.', verbose_name='Currency')), - ('camp', models.ForeignKey(to='camps.Camp', help_text='The camp to which this expense relates to.', verbose_name='Camp')), - ('covered_by', models.ForeignKey(to=settings.AUTH_USER_MODEL, blank=True, help_text='Which user, if any, covered this expense.', verbose_name='Covered by', null=True)), + ('camp', models.ForeignKey(on_delete=models.PROTECT, to='camps.Camp', help_text='The camp to which this expense relates to.', verbose_name='Camp')), + ('covered_by', models.ForeignKey(on_delete=models.PROTECT, to=settings.AUTH_USER_MODEL, blank=True, help_text='Which user, if any, covered this expense.', verbose_name='Covered by', null=True)), ], options={ 'verbose_name_plural': 'Expenses', @@ -67,8 +67,8 @@ class Migration(migrations.Migration): ('uuid', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)), ('cost', models.DecimalField(default=1500.0, decimal_places=2, help_text='What the user should/is willing to pay for this signup.', verbose_name='Cost', max_digits=7)), ('paid', models.BooleanField(help_text='Whether the user has paid.', verbose_name='Paid?', default=False)), - ('camp', models.ForeignKey(to='camps.Camp', help_text='The camp that has been signed up for.', verbose_name='Camp')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, help_text='The user that has signed up.', verbose_name='User')), + ('camp', models.ForeignKey(on_delete=models.PROTECT, to='camps.Camp', help_text='The camp that has been signed up for.', verbose_name='Camp')), + ('user', models.ForeignKey(on_delete=models.PROTECT, to=settings.AUTH_USER_MODEL, help_text='The user that has signed up.', verbose_name='User')), ], options={ 'verbose_name_plural': 'Signups', diff --git a/src/camps/migrations/0023_camp_shortslug.py b/src/camps/migrations/0023_camp_shortslug.py new file mode 100644 index 00000000..27f715aa --- /dev/null +++ b/src/camps/migrations/0023_camp_shortslug.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0022_camp_colour'), + ] + + operations = [ + migrations.AddField( + model_name='camp', + name='shortslug', + field=models.SlugField(blank=True, help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', verbose_name='Short Slug'), + ), + ] diff --git a/src/camps/migrations/0024_populate_camp_shortslugs.py b/src/camps/migrations/0024_populate_camp_shortslugs.py new file mode 100644 index 00000000..2033c633 --- /dev/null +++ b/src/camps/migrations/0024_populate_camp_shortslugs.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:45 +from __future__ import unicode_literals + +from django.db import migrations + +def populate_camp_shortslugs(apps, schema_editor): + Camp = apps.get_model('camps', 'Camp') + for camp in Camp.objects.all(): + if not camp.shortslug: + camp.shortslug = camp.slug + camp.save() + +class Migration(migrations.Migration): + dependencies = [ + ('camps', '0023_camp_shortslug'), + ] + + operations = [ + migrations.RunPython(populate_camp_shortslugs), + ] + diff --git a/src/camps/migrations/0025_auto_20180318_1250.py b/src/camps/migrations/0025_auto_20180318_1250.py new file mode 100644 index 00000000..d7c596e1 --- /dev/null +++ b/src/camps/migrations/0025_auto_20180318_1250.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0024_populate_camp_shortslugs'), + ] + + operations = [ + migrations.AlterField( + model_name='camp', + name='shortslug', + field=models.SlugField(help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', verbose_name='Short Slug'), + ), + ] diff --git a/src/camps/migrations/0026_auto_20180506_1633.py b/src/camps/migrations/0026_auto_20180506_1633.py new file mode 100644 index 00000000..f99bcb1e --- /dev/null +++ b/src/camps/migrations/0026_auto_20180506_1633.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.4 on 2018-05-06 14:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0025_auto_20180318_1250'), + ] + + operations = [ + migrations.AddField( + model_name='camp', + name='call_for_participation_open', + field=models.BooleanField(default=False, help_text='Check if the Call for Participation is open for this camp'), + ), + migrations.AddField( + model_name='camp', + name='call_for_sponsors_open', + field=models.BooleanField(default=False, help_text='Check if the Call for Sponsors is open for this camp'), + ), + ] diff --git a/src/camps/migrations/0027_auto_20180525_1019.py b/src/camps/migrations/0027_auto_20180525_1019.py new file mode 100644 index 00000000..daf8db5b --- /dev/null +++ b/src/camps/migrations/0027_auto_20180525_1019.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.4 on 2018-05-25 08:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0026_auto_20180506_1633'), + ] + + operations = [ + migrations.AddField( + model_name='camp', + name='call_for_participation', + field=models.TextField(blank=True, help_text='The CFP markdown for this Camp'), + ), + migrations.AddField( + model_name='camp', + name='call_for_sponsors', + field=models.TextField(blank=True, help_text='The CFS markdown for this Camp'), + ), + ] diff --git a/src/camps/migrations/0028_auto_20180525_1025.py b/src/camps/migrations/0028_auto_20180525_1025.py new file mode 100644 index 00000000..e7976e32 --- /dev/null +++ b/src/camps/migrations/0028_auto_20180525_1025.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.4 on 2018-05-25 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0027_auto_20180525_1019'), + ] + + operations = [ + migrations.AlterField( + model_name='camp', + name='call_for_participation', + field=models.TextField(blank=True, default='The Call For Participation for this Camp has not been written yet', help_text='The CFP markdown for this Camp'), + ), + migrations.AlterField( + model_name='camp', + name='call_for_sponsors', + field=models.TextField(blank=True, default='The Call For Sponsors for this Camp has not been written yet', help_text='The CFS markdown for this Camp'), + ), + ] diff --git a/src/camps/migrations/0029_auto_20180815_2018.py b/src/camps/migrations/0029_auto_20180815_2018.py new file mode 100644 index 00000000..e65b6d0c --- /dev/null +++ b/src/camps/migrations/0029_auto_20180815_2018.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1 on 2018-08-15 18:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0028_auto_20180525_1025'), + ] + + operations = [ + migrations.AlterModelOptions( + name='camp', + options={'ordering': ['-title'], 'permissions': (('infodesk_permission', 'Infodesk permission'),), 'verbose_name': 'Camp', 'verbose_name_plural': 'Camps'}, + ), + ] diff --git a/src/camps/migrations/0030_camp_light_text.py b/src/camps/migrations/0030_camp_light_text.py new file mode 100644 index 00000000..9663aba5 --- /dev/null +++ b/src/camps/migrations/0030_camp_light_text.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-08-18 15:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0029_auto_20180815_2018'), + ] + + operations = [ + migrations.AddField( + model_name='camp', + name='light_text', + field=models.BooleanField(default=True, help_text='Check if this camps colour requires white text, uncheck if black text is better'), + ), + ] diff --git a/src/camps/migrations/0031_auto_20180830_0014.py b/src/camps/migrations/0031_auto_20180830_0014.py new file mode 100644 index 00000000..07b06c4e --- /dev/null +++ b/src/camps/migrations/0031_auto_20180830_0014.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.4 on 2018-08-29 22:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0030_camp_light_text'), + ] + + operations = [ + migrations.CreateModel( + name='Permission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'permissions': (('backoffice_permission', 'BackOffice access'), ('orgateam_permission', 'Orga Team permissions set'), ('infoteam_permission', 'Info Team permissions set'), ('economyteam_permission', 'Economy Team permissions set'), ('contentteam_permission', 'Content Team permissions set'), ('expense_create_permission', 'Expense Create permission')), + 'default_permissions': (), + 'managed': False, + }, + ), + migrations.AlterModelOptions( + name='camp', + options={'ordering': ['-title'], 'verbose_name': 'Camp', 'verbose_name_plural': 'Camps'}, + ), + ] diff --git a/src/camps/migrations/0032_auto_20180917_1754.py b/src/camps/migrations/0032_auto_20180917_1754.py new file mode 100644 index 00000000..b4fe20a7 --- /dev/null +++ b/src/camps/migrations/0032_auto_20180917_1754.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.4 on 2018-09-17 15:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0031_auto_20180830_0014'), + ] + + operations = [ + migrations.AlterModelOptions( + name='permission', + options={'default_permissions': (), 'managed': False, 'permissions': (('backoffice_permission', 'BackOffice access'), ('orgateam_permission', 'Orga Team permissions set'), ('infoteam_permission', 'Info Team permissions set'), ('economyteam_permission', 'Economy Team permissions set'), ('contentteam_permission', 'Content Team permissions set'), ('expense_create_permission', 'Expense Create permission'), ('revenue_create_permission', 'Revenue Create permission'))}, + ), + ] diff --git a/src/camps/mixins.py b/src/camps/mixins.py index 5234c1c7..35a0fd53 100644 --- a/src/camps/mixins.py +++ b/src/camps/mixins.py @@ -1,5 +1,6 @@ from camps.models import Camp from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property class CampViewMixin(object): @@ -8,19 +9,49 @@ class CampViewMixin(object): It also filters out objects that belong to other camps when the queryset has a direct relation to the Camp model. """ + def dispatch(self, request, *args, **kwargs): - self.camp = get_object_or_404(Camp, slug=self.kwargs['camp_slug']) + self.camp = get_object_or_404(Camp, slug=self.kwargs["camp_slug"]) return super().dispatch(request, *args, **kwargs) def get_queryset(self): - queryset = super(CampViewMixin, self).get_queryset() - if queryset: - # check if we have a foreignkey to Camp, filter if so - for field in queryset.model._meta.fields: - if field.name=="camp" and field.related_model._meta.label == "camps.Camp": - return queryset.filter(camp=self.camp) + queryset = super().get_queryset() - # Camp relation not found, or queryset is empty, return it unaltered - return queryset + # if this queryset is empty return it right away, because nothing for us to do + if not queryset: + return queryset + # get the camp_filter from the model + camp_filter = self.model.get_camp_filter() + + # Let us deal with eveything as a list + if isinstance(camp_filter, str): + camp_filter = [camp_filter] + + for _filter in camp_filter: + # add camp to the filter_dict + filter_dict = {_filter: self.camp} + + # get pk from kwargs if we have it + if hasattr(self, 'pk_url_kwarg'): + pk = self.kwargs.get(self.pk_url_kwarg) + if pk is not None: + # We should also filter for the pk of the object + filter_dict['pk'] = pk + + # get slug from kwargs if we have it + if hasattr(self, 'slug_url_kwarg'): + slug = self.kwargs.get(self.slug_url_kwarg) + if slug is not None and (pk is None or self.query_pk_and_slug): + # we should also filter for the slug of the object + filter_dict[self.get_slug_field()] = slug + + # do the filtering and return the result + result = queryset.filter(**filter_dict) + if result.exists(): + # we got some results with this camp_filter, return now + return result + + # no camp_filter returned any results, return an empty queryset + return result diff --git a/src/camps/models.py b/src/camps/models.py index 809dcc32..cb4b1c18 100644 --- a/src/camps/models.py +++ b/src/camps/models.py @@ -11,6 +11,24 @@ import logging logger = logging.getLogger("bornhack.%s" % __name__) +class Permission(models.Model): + """ + An unmanaged field-less model which holds our non-model permissions (such as team permission sets) + """ + class Meta: + managed = False + default_permissions=() + permissions = ( + ("backoffice_permission", "BackOffice access"), + ("orgateam_permission", "Orga Team permissions set"), + ("infoteam_permission", "Info Team permissions set"), + ("economyteam_permission", "Economy Team permissions set"), + ("contentteam_permission", "Content Team permissions set"), + ("expense_create_permission", "Expense Create permission"), + ("revenue_create_permission", "Revenue Create permission"), + ) + + class Camp(CreatedUpdatedModel, UUIDModel): class Meta: verbose_name = 'Camp' @@ -34,6 +52,11 @@ class Camp(CreatedUpdatedModel, UUIDModel): help_text='The url slug to use for this camp' ) + shortslug = models.SlugField( + verbose_name='Short Slug', + help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', + ) + buildup = DateTimeRangeField( verbose_name='Buildup Period', help_text='The camp buildup period.', @@ -60,6 +83,33 @@ class Camp(CreatedUpdatedModel, UUIDModel): max_length=7 ) + light_text = models.BooleanField( + default=True, + help_text='Check if this camps colour requires white text, uncheck if black text is better', + ) + + call_for_participation_open = models.BooleanField( + help_text='Check if the Call for Participation is open for this camp', + default=False, + ) + + call_for_participation = models.TextField( + blank=True, + help_text='The CFP markdown for this Camp', + default='The Call For Participation for this Camp has not been written yet', + ) + + call_for_sponsors_open = models.BooleanField( + help_text='Check if the Call for Sponsors is open for this camp', + default=False, + ) + + call_for_sponsors = models.TextField( + blank=True, + help_text='The CFS markdown for this Camp', + default='The Call For Sponsors for this Camp has not been written yet', + ) + def get_absolute_url(self): return reverse('camp_detail', kwargs={'camp_slug': self.slug}) @@ -86,7 +136,7 @@ class Camp(CreatedUpdatedModel, UUIDModel): @property def event_types(self): - # return all event types with at least one event in this camp + """ Return all event types with at least one event in this camp """ return EventType.objects.filter(event__instances__isnull=False, event__camp=self).distinct() @property @@ -174,17 +224,3 @@ class Camp(CreatedUpdatedModel, UUIDModel): ''' return self.get_days('teardown') - @property - def call_for_speakers_open(self): - if self.camp.upper < timezone.now(): - return False - else: - return True - - @property - def call_for_sponsors_open(self): - """ Keep call for sponsors open 30 days after camp end """ - if self.camp.upper + timedelta(days=30) < timezone.now(): - return False - else: - return True diff --git a/src/camps/templates/bornhack-2017_camp_detail.html b/src/camps/templates/bornhack-2017_camp_detail.html index 826622dd..dbf6e255 100644 --- a/src/camps/templates/bornhack-2017_camp_detail.html +++ b/src/camps/templates/bornhack-2017_camp_detail.html @@ -52,7 +52,7 @@ {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
-
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for speakers.
+
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for participation.
diff --git a/src/camps/templates/bornhack-2018_camp_detail.html b/src/camps/templates/bornhack-2018_camp_detail.html index e3efbdb0..93242151 100644 --- a/src/camps/templates/bornhack-2018_camp_detail.html +++ b/src/camps/templates/bornhack-2018_camp_detail.html @@ -52,7 +52,7 @@ {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
-
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for speakers.
+
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for participation.
diff --git a/src/camps/templates/bornhack-2019_camp_detail.html b/src/camps/templates/bornhack-2019_camp_detail.html index 774fcd29..3c29099a 100644 --- a/src/camps/templates/bornhack-2019_camp_detail.html +++ b/src/camps/templates/bornhack-2019_camp_detail.html @@ -27,7 +27,7 @@
- Bornhack 2019 will be the third BornHack. It will take place from August 16th to August 23rd 2019 on the Danish island of Bornholm. + Bornhack 2019 will be the fourth BornHack. It will take place from Thursday the 8th of August to Thursday the 15th of August 2019 at a our new venue on the Danish island of Funen.
@@ -52,7 +52,7 @@ {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
-
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for speakers.
+
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for participation.
diff --git a/src/camps/templates/bornhack-2020_camp_detail.html b/src/camps/templates/bornhack-2020_camp_detail.html new file mode 100644 index 00000000..f13b0311 --- /dev/null +++ b/src/camps/templates/bornhack-2020_camp_detail.html @@ -0,0 +1,92 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static from staticfiles %} +{% load imageutils %} +{% block content %} +
+
+ +
+
+ +
+
+
+ BornHack is a 7 day outdoor tent camp where hackers, makers and people with an interest in technology or security come together to celebrate technology, socialise, learn and have fun. +
+
+
+ {% thumbnail 'img/bornhack-2016/esbjerg' '1600x988-B12A2610.jpg' 'The family area at BornHack 2016' %} +
+
+ + +
+
+ {% thumbnail 'img/bornhack-2016/esbjerg' '1600x1000-B12A2398.jpg' 'A random hackers laptop' %} +
+
+
+ Bornhack 2020 will be the fifth BornHack. It will take place from August 11th to August 18th 2020 on the Danish island of Bornholm. +
+
+
+ +
+ +
+
+
+ The BornHack team looks forward to organising another great event for the hacker community. We still need volunteers, so please let us know if you want to help! +
+
+
+ {% thumbnail 'img/bornhack-2016/esbjerg' '1600x988-B12A2631.jpg' 'The BornHack 2016 organiser team' %} +
+
+ +
+ +
+
+ {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %} +
+
+
We want to encourage hackers, makers, politicians, activists, developers, artists, sysadmins, engineers with something to say to read our call for participation.
+
+
+ +
+ +
+
+
+ BornHack aims to keep ticket prices affordable for everyone and to that end we need sponsors. Please see our call for sponsors if you want to sponsor us, or if you work for a company you think might be able to help. +
+
+
+ {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5265.JPG' 'Organisers thanking the BornHack 2016 sponsors' %} +
+
+ +
+ +
+
+

You are very welcome to ask questions and show your interest on our different channels:

+{% include 'includes/contact.html' %} +
+
+

+ {% thumbnail 'img/bornhack-2016/fonsmark' 'FA0_1983.JPG' 'Happy organisers welcoming people at the entrance to BornHack 2016' %} + {% thumbnail 'img/bornhack-2016/fonsmark' 'FA0_1986.JPG' 'A bus full of hackers arrive at BornHack 2016' %} + {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5126.JPG' 'Late night hacking at Baconsvin village at BornHack 2016' %} + {% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5168.JPG' '#irl_bar by night at BornHack 2016' %} + {% thumbnail 'img/bornhack-2016/esbjerg' '1600x900-B12A2452.jpg' 'Soldering the BornHack 2016 badge' %} + {% thumbnail 'img/bornhack-2016/esbjerg' '1600x900-B12A2608.jpg' 'Colored lights at night' %} + {% thumbnail 'img/bornhack-2016/fonsmark' 'FA0_1961.JPG' 'BornHack' %} + {% thumbnail 'img/bornhack-2016/esbjerg' '1600x900-B12A2485.jpg' 'Colored light in the grass' %} + {% thumbnail 'img/bornhack-2016/esbjerg' '1600x988-B12A2624.jpg' 'Working on decorations' %} + {% thumbnail 'img/bornhack-2016/esbjerg' '1600x900-B12A2604.jpg' 'Sitting around the campfire at BornHack 2016' %} +

+{% endblock content %} diff --git a/src/camps/templates/camp_list.html b/src/camps/templates/camp_list.html index 33b25296..abc8537d 100644 --- a/src/camps/templates/camp_list.html +++ b/src/camps/templates/camp_list.html @@ -24,7 +24,7 @@ {{ listcamp.buildup.lower }} to {{ listcamp.buildup.upper }} {{ listcamp.camp.lower }} to {{ listcamp.camp.upper }} {{ listcamp.teardown.lower }} to {{ listcamp.teardown.upper }} - {{ listcamp.colour }} + {% if listcamp.light_text %}{% else %}{% endif %}{{ listcamp.colour }} {% empty %} diff --git a/src/camps/utils.py b/src/camps/utils.py index 81454fb7..c4e2838e 100644 --- a/src/camps/utils.py +++ b/src/camps/utils.py @@ -1,5 +1,7 @@ from camps.models import Camp from django.utils import timezone +from django.contrib import admin + def get_current_camp(): try: @@ -7,3 +9,40 @@ def get_current_camp(): except Camp.DoesNotExist: return False + +class CampPropertyListFilter(admin.SimpleListFilter): + """ + SimpleListFilter to filter models by camp when camp is + a property and not a real model field. + """ + title = 'Camp' + parameter_name = 'camp' + + def lookups(self, request, model_admin): + # get the current queryset + qs = model_admin.get_queryset(request) + + # get a list of the unique camps in the current queryset + unique_camps = set([item.camp for item in qs]) + + # loop over camps and yield each as a tuple + for camp in unique_camps: + yield (camp.slug, camp.title) + + def queryset(self, request, queryset): + # if self.value() is None return everything + if not self.value(): + return queryset + + # ok, get the Camp + try: + camp = Camp.objects.get(slug=self.value()) + except Camp.DoesNotExist: + # camp not found, return nothing + return queryset.model.objects.none() + + # filter out items related to other camps + for item in queryset: + if item.camp != camp: + queryset = queryset.exclude(pk=item.pk) + return queryset diff --git a/src/camps/views.py b/src/camps/views.py index 1ba41d2a..9c2633cf 100644 --- a/src/camps/views.py +++ b/src/camps/views.py @@ -18,19 +18,29 @@ class CampRedirectView(CampViewMixin, View): camp = Camp.objects.get( camp__contains=now ) - logger.debug("Redirecting to camp '%s' for page '%s' because it is now!" % (camp.slug, kwargs['page'])) + return redirect(kwargs['page'], camp_slug=camp.slug) except Camp.DoesNotExist: - # find the closest camp in the past + pass + + # no ongoing camp, find the closest camp in the past + try: prevcamp = Camp.objects.filter( camp__endswith__lt=now - ).order_by('-camp')[0] + ).order_by('-camp').first() + except Camp.DoesNotExist: + prevcamp = None - # find the closest upcoming camp + # find the closest upcoming camp + try: nextcamp = Camp.objects.filter( camp__startswith__gt=now - ).order_by('camp')[0] + ).order_by('camp').first() + except Camp.DoesNotExist: + nextcamp = None + percentpassed = False + if prevcamp and nextcamp: # find the number of days between the two camps daysbetween = (nextcamp.camp.lower - prevcamp.camp.upper).days @@ -40,14 +50,15 @@ class CampRedirectView(CampViewMixin, View): # find the percentage of time passed percentpassed = (dayssinceprevcamp / daysbetween) * 100 - # do the redirect - if percentpassed > settings.CAMP_REDIRECT_PERCENT: - camp = nextcamp - else: - camp = prevcamp - - logger.debug("Redirecting to camp '%s' for page '%s' because %s%% of the time between the camps passed" % (camp.slug, kwargs['page'], int(percentpassed))) + # figure out where to redirect + if percentpassed > settings.CAMP_REDIRECT_PERCENT or not prevcamp: + # either we have no previous camp, or we have both and more than settings.CAMP_REDIRECT_PERCENT has passed, so redirect to the next camp + camp = nextcamp + else: + # either we have no next camp, or we have both and less than settings.CAMP_REDIRECT_PERCENT has passed, so redirect to the previous camp + camp = prevcamp + # do the redirect return redirect(kwargs['page'], camp_slug=camp.slug) diff --git a/src/economy/__init__.py b/src/economy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/economy/admin.py b/src/economy/admin.py new file mode 100644 index 00000000..26081059 --- /dev/null +++ b/src/economy/admin.py @@ -0,0 +1,58 @@ +from django.contrib import admin +from .models import Expense, Reimbursement, Revenue + + +### expenses + +def approve_expenses(modeladmin, request, queryset): + for expense in queryset.all(): + expense.approve(request) +approve_expenses.short_description = "Approve Expenses" + + +def reject_expenses(modeladmin, request, queryset): + for expense in queryset.all(): + expense.reject(request) +reject_expenses.short_description = "Reject Expenses" + + +@admin.register(Expense) +class ExpenseAdmin(admin.ModelAdmin): + list_filter = ['camp', 'responsible_team', 'approved', 'user'] + list_display = ['user', 'description', 'invoice_date', 'amount', 'camp', 'responsible_team', 'approved', 'reimbursement'] + search_fields = ['description', 'amount', 'uuid'] + actions = [approve_expenses, reject_expenses] + + +### revenues + +def approve_revenues(modeladmin, request, queryset): + for revenue in queryset.all(): + revenue.approve(request) +approve_revenues.short_description = "Approve Revenues" + + +def reject_revenues(modeladmin, request, queryset): + for revenue in queryset.all(): + revenue.reject(request) +reject_revenues.short_description = "Reject Revenues" + + +@admin.register(Revenue) +class RevenueAdmin(admin.ModelAdmin): + list_filter = ['camp', 'responsible_team', 'approved', 'user'] + list_display = ['user', 'description', 'invoice_date', 'amount', 'camp', 'responsible_team', 'approved'] + search_fields = ['description', 'amount', 'user'] + actions = [approve_revenues, reject_revenues] + + +### reimbursements + +@admin.register(Reimbursement) +class ReimbursementAdmin(admin.ModelAdmin): + def get_amount(self, obj): + return obj.amount + list_filter = ['camp', 'user', 'reimbursement_user', 'paid'] + list_display = ['camp', 'user', 'reimbursement_user', 'paid', 'notes', 'get_amount'] + search_fields = ['user__username', 'reimbursement_user__username', 'notes'] + diff --git a/src/economy/apps.py b/src/economy/apps.py new file mode 100644 index 00000000..03724994 --- /dev/null +++ b/src/economy/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EconomyConfig(AppConfig): + name = 'economy' diff --git a/src/economy/email.py b/src/economy/email.py new file mode 100644 index 00000000..c3620bac --- /dev/null +++ b/src/economy/email.py @@ -0,0 +1,86 @@ +import os + +from django.conf import settings + +from utils.email import add_outgoing_email + +# expense emails + +def send_accountingsystem_expense_email(expense): + """ + Sends an email to the accountingsystem with the invoice as an attachment, + and with the expense uuid and description in email subject + """ + add_outgoing_email( + "emails/accountingsystem_expense_email.txt", + formatdict=dict(expense=expense), + subject="Expense %s for %s" % (expense.pk, expense.camp.title), + to_recipients=[settings.ACCOUNTINGSYSTEM_EMAIL], + attachment=expense.invoice.read(), + attachment_filename=os.path.basename(expense.invoice.file.name), + ) + + +def send_expense_approved_email(expense): + """ + Sends an expense-approved email to the user who created the expense + """ + add_outgoing_email( + "emails/expense_approved_email.txt", + formatdict=dict(expense=expense), + subject="Your expense for %s has been approved." % expense.camp.title, + to_recipients=[expense.user.emailaddress_set.get(primary=True).email], + ) + + +def send_expense_rejected_email(expense): + """ + Sends an expense-rejected email to the user who created the expense + """ + add_outgoing_email( + "emails/expense_rejected_email.txt", + formatdict=dict(expense=expense), + subject="Your expense for %s has been rejected." % expense.camp.title, + to_recipients=[expense.user.emailaddress_set.get(primary=True).email], + ) + +# revenue emails + +def send_accountingsystem_revenue_email(revenue): + """ + Sends an email to the accountingsystem with the invoice as an attachment, + and with the revenue uuid and description in email subject + """ + add_outgoing_email( + "emails/accountingsystem_revenue_email.txt", + formatdict=dict(revenue=revenue), + subject="Revenue %s for %s" % (revenue.pk, revenue.camp.title), + to_recipients=[settings.ACCOUNTINGSYSTEM_EMAIL], + attachment=revenue.invoice.read(), + attachment_filename=os.path.basename(revenue.invoice.file.name), + ) + + +def send_revenue_approved_email(revenue): + """ + Sends a revenue-approved email to the user who created the revenue + """ + add_outgoing_email( + "emails/revenue_approved_email.txt", + formatdict=dict(revenue=revenue), + subject="Your revenue for %s has been approved." % revenue.camp.title, + to_recipients=[revenue.user.emailaddress_set.get(primary=True).email], + ) + + +def send_revenue_rejected_email(revenue): + """ + Sends an revenue-rejected email to the user who created the revenue + """ + add_outgoing_email( + "emails/revenue_rejected_email.txt", + formatdict=dict(revenue=revenue), + subject="Your revenue for %s has been rejected." % revenue.camp.title, + to_recipients=[revenue.user.emailaddress_set.get(primary=True).email], + ) + diff --git a/src/economy/forms.py b/src/economy/forms.py new file mode 100644 index 00000000..28bc3b2d --- /dev/null +++ b/src/economy/forms.py @@ -0,0 +1,58 @@ +import os, magic, copy + +from django import forms +from .models import Expense, Revenue + + +class CleanInvoiceForm(forms.ModelForm): + """ + We have to define this form explicitly because we want our ImageField to accept PDF files as well as images, + and we cannot change the clean_* methods with an autogenerated form from inside views.py + """ + invoice = forms.FileField() + + def clean_invoice(self): + # get the uploaded file from cleaned_data + uploaded_file = self.cleaned_data['invoice'] + # is this a valid image? + try: + # create an ImageField instance + im = forms.ImageField() + # now check if the file is a valid image + im.to_python(uploaded_file) + except forms.ValidationError: + # file is not a valid image, so check if it's a pdf + # do a deep copy so we dont mess with the file object we might be passing on + testfile = copy.deepcopy(uploaded_file) + # read the uploaded file into memory (the webserver limits uploads to a reasonable max size so this should be safe) + mimetype = magic.from_buffer(testfile.open().read(), mime=True) + if mimetype != "application/pdf": + raise forms.ValidationError("Only images and PDF files allowed") + + # this is either a valid image, or has mimetype application/pdf, all good + return uploaded_file + + +class ExpenseCreateForm(CleanInvoiceForm): + class Meta: + model = Expense + fields = ['description', 'amount', 'invoice', 'invoice_date', 'paid_by_bornhack', 'responsible_team'] + + +class ExpenseUpdateForm(forms.ModelForm): + class Meta: + model = Expense + fields = ['description', 'amount', 'invoice_date', 'paid_by_bornhack', 'responsible_team'] + + +class RevenueCreateForm(CleanInvoiceForm): + class Meta: + model = Revenue + fields = ['description', 'amount', 'invoice', 'invoice_date', 'responsible_team'] + + +class RevenueUpdateForm(forms.ModelForm): + class Meta: + model = Revenue + fields = ['description', 'amount', 'invoice_date', 'responsible_team'] + diff --git a/src/economy/migrations/0001_initial.py b/src/economy/migrations/0001_initial.py new file mode 100644 index 00000000..ca016507 --- /dev/null +++ b/src/economy/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 2.0.4 on 2018-08-29 22:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('teams', '0049_auto_20180815_1119'), + ('camps', '0031_auto_20180830_0014'), + ] + + operations = [ + migrations.CreateModel( + name='Expense', + 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)), + ('amount', models.DecimalField(decimal_places=2, help_text='The amount of this expense in DKK. Must match the amount on the invoice uploaded below.', max_digits=12)), + ('description', models.CharField(help_text='A short description of this expense. Please keep it meningful as it helps the Economy team a lot when categorising expenses. 200 characters or fewer.', max_length=200)), + ('paid_by_bornhack', models.BooleanField(default=True, help_text='Leave checked if this expense was paid by BornHack. Uncheck if you need a reimbursement for this expense.')), + ('invoice', models.ImageField(help_text='The invoice for this expense. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted.', upload_to='expenses/')), + ('approved', models.NullBooleanField(default=None, help_text='True if this expense has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.')), + ('notes', models.TextField(blank=True, help_text='Economy Team notes for this expense. Only visible to the Economy team and the submitting user.')), + ('camp', models.ForeignKey(help_text='The camp to which this expense belongs', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='camps.Camp')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Reimbursement', + 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)), + ('notes', models.TextField(blank=True, help_text='Economy Team notes for this reimbursement. Only visible to the Economy team and the related user.')), + ('paid', models.BooleanField(default=False, help_text='Check when this reimbursement has been paid to the user')), + ('camp', models.ForeignKey(help_text='The camp to which this reimbursement belongs', on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to='camps.Camp')), + ('reimbursement_user', models.ForeignKey(help_text='The user this reimbursement belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(help_text='The user who created this reimbursement.', on_delete=django.db.models.deletion.PROTECT, related_name='created_reimbursements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='expense', + name='reimbursement', + field=models.ForeignKey(blank=True, help_text='The reimbursement for this expense, if any. This is a dual-purpose field. If expense.paid_by_bornhack is true then expense.reimbursement references the reimbursement which this expense is created to cover. If expense.paid_by_bornhack is false then expense.reimbursement references the reimbursement which reimbursed this expense.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='economy.Reimbursement'), + ), + migrations.AddField( + model_name='expense', + name='responsible_team', + field=models.ForeignKey(help_text='The team to which this Expense belongs. A team responsible will need to approve the expense. When in doubt pick the Economy team.', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='teams.Team'), + ), + migrations.AddField( + model_name='expense', + name='user', + field=models.ForeignKey(help_text='The user to which this expense belongs', on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/economy/migrations/0002_revenue.py b/src/economy/migrations/0002_revenue.py new file mode 100644 index 00000000..216f28eb --- /dev/null +++ b/src/economy/migrations/0002_revenue.py @@ -0,0 +1,40 @@ +# Generated by Django 2.0.4 on 2018-09-16 13:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0057_order_notes'), + ('teams', '0049_auto_20180815_1119'), + ('camps', '0031_auto_20180830_0014'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('economy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Revenue', + 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)), + ('amount', models.DecimalField(decimal_places=2, help_text='The amount of this revenue in DKK. Must match the amount on the documentation uploaded below.', max_digits=12)), + ('description', models.CharField(help_text='A short description of this revenue. Please keep it meningful as it helps the Economy team a lot when categorising revenue. 200 characters or fewer.', max_length=200)), + ('invoice', models.ImageField(help_text='The invoice file for this revenue. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted, as well as PDF.', upload_to='revenues/')), + ('approved', models.NullBooleanField(default=None, help_text='True if this Revenue has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.')), + ('notes', models.TextField(blank=True, help_text='Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.')), + ('camp', models.ForeignKey(help_text='The camp to which this revenue belongs', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='camps.Camp')), + ('invoice_fk', models.ForeignKey(help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='shop.Invoice')), + ('responsible_team', models.ForeignKey(help_text='The team to which this revenue belongs. When in doubt pick the Economy team.', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='teams.Team')), + ('user', models.ForeignKey(help_text='The user who submitted this revenue', on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/economy/migrations/0003_auto_20180917_1933.py b/src/economy/migrations/0003_auto_20180917_1933.py new file mode 100644 index 00000000..f5b9036c --- /dev/null +++ b/src/economy/migrations/0003_auto_20180917_1933.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.4 on 2018-09-17 17:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0002_revenue'), + ] + + operations = [ + migrations.AlterField( + model_name='revenue', + name='invoice_fk', + field=models.ForeignKey(blank=True, help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revenues', to='shop.Invoice'), + ), + ] diff --git a/src/economy/migrations/0004_auto_20181120_1835.py b/src/economy/migrations/0004_auto_20181120_1835.py new file mode 100644 index 00000000..bfc9c5b6 --- /dev/null +++ b/src/economy/migrations/0004_auto_20181120_1835.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.3 on 2018-11-20 17:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0003_auto_20180917_1933'), + ] + + operations = [ + migrations.AlterField( + model_name='reimbursement', + name='user', + field=models.ForeignKey(help_text='The economy team member who created this reimbursement.', on_delete=django.db.models.deletion.PROTECT, related_name='created_reimbursements', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/economy/migrations/0005_auto_20190120_1532.py b/src/economy/migrations/0005_auto_20190120_1532.py new file mode 100644 index 00000000..8e2792b9 --- /dev/null +++ b/src/economy/migrations/0005_auto_20190120_1532.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.3 on 2019-01-20 14:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0004_auto_20181120_1835'), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='invoice_date', + field=models.DateTimeField(blank=True, help_text='The invoice date for this Expense. This must match the invoice date on the documentation uploaded below.', null=True), + ), + migrations.AddField( + model_name='revenue', + name='invoice_date', + field=models.DateTimeField(blank=True, help_text='The invoice date for this Revenue. This must match the invoice date on the documentation uploaded below.', null=True), + ), + ] diff --git a/src/economy/migrations/0006_auto_20190120_1642.py b/src/economy/migrations/0006_auto_20190120_1642.py new file mode 100644 index 00000000..8483a5f2 --- /dev/null +++ b/src/economy/migrations/0006_auto_20190120_1642.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.3 on 2019-01-20 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('economy', '0005_auto_20190120_1532'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='invoice_date', + field=models.DateField(blank=True, help_text='The invoice date for this Expense. This must match the invoice date on the documentation uploaded below.', null=True), + ), + migrations.AlterField( + model_name='revenue', + name='invoice_date', + field=models.DateField(blank=True, help_text='The invoice date for this Revenue. This must match the invoice date on the documentation uploaded below.', null=True), + ), + ] diff --git a/src/economy/migrations/__init__.py b/src/economy/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/economy/mixins.py b/src/economy/mixins.py new file mode 100644 index 00000000..40a683f9 --- /dev/null +++ b/src/economy/mixins.py @@ -0,0 +1,41 @@ +from django.http import HttpResponseRedirect, Http404 + + +class ExpensePermissionMixin(object): + """ + This mixin checks if request.user submitted the Expense, or if request.user has camps.economyteam_permission + """ + def get_object(self, queryset=None): + obj = super().get_object(queryset=queryset) + if obj.user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'): + return obj + else: + # the current user is different from the user who submitted the expense, and current user is not in the economy team; fuckery is afoot, no thanks + raise Http404() + + +class RevenuePermissionMixin(object): + """ + This mixin checks if request.user submitted the Revenue, or if request.user has camps.economyteam_permission + """ + def get_object(self, queryset=None): + obj = super().get_object(queryset=queryset) + if obj.user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'): + return obj + else: + # the current user is different from the user who submitted the revenue, and current user is not in the economy team; fuckery is afoot, no thanks + raise Http404() + + +class ReimbursementPermissionMixin(object): + """ + This mixin checks if request.user owns the Reimbursement, or if request.user has camps.economyteam_permission + """ + def get_object(self, queryset=None): + obj = super().get_object(queryset=queryset) + if obj.reimbursement_user == self.request.user or self.request.user.has_perm('camps.economyteam_permission'): + return obj + else: + # the current user is different from the user who "owns" the reimbursement, and current user is not in the economy team; fuckery is afoot, no thanks + raise Http404() + diff --git a/src/economy/models.py b/src/economy/models.py new file mode 100644 index 00000000..a2eb6531 --- /dev/null +++ b/src/economy/models.py @@ -0,0 +1,310 @@ +import os + +from django.db import models +from django.conf import settings +from django.db import models +from django.contrib import messages +from django.core.exceptions import ValidationError + +from utils.models import CampRelatedModel, UUIDModel +from .email import * + +class Revenue(CampRelatedModel, UUIDModel): + """ + The Revenue model represents any type of income for BornHack. + Most Revenue objects will have a FK to the Invoice model, but only if the revenue relates directly to an Invoice in our system. + Other Revenue objects (such as money returned from bottle deposits) will not have a related BornHack Invoice object. + """ + camp = models.ForeignKey( + 'camps.Camp', + on_delete=models.PROTECT, + related_name='revenues', + help_text='The camp to which this revenue belongs', + ) + + user = models.ForeignKey( + 'auth.User', + on_delete=models.PROTECT, + related_name='revenues', + help_text='The user who submitted this revenue', + ) + + amount = models.DecimalField( + decimal_places=2, + max_digits=12, + help_text='The amount of this revenue in DKK. Must match the amount on the documentation uploaded below.', + ) + + description = models.CharField( + max_length=200, + help_text='A short description of this revenue. Please keep it meningful as it helps the Economy team a lot when categorising revenue. 200 characters or fewer.', + ) + + invoice = models.ImageField( + help_text='The invoice file for this revenue. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted, as well as PDF.', + upload_to='revenues/', + ) + + invoice_date = models.DateField( + help_text='The invoice date for this Revenue. This must match the invoice date on the documentation uploaded below.', + blank=True, + null=True, + ) + + invoice_fk = models.ForeignKey( + 'shop.Invoice', + on_delete=models.PROTECT, + related_name='revenues', + help_text='The Invoice object to which this Revenue object relates. Can be None if this revenue does not have a related BornHack Invoice.', + blank=True, + null=True, + ) + + responsible_team = models.ForeignKey( + 'teams.Team', + on_delete=models.PROTECT, + related_name='revenues', + help_text='The team to which this revenue belongs. When in doubt pick the Economy team.' + ) + + approved = models.NullBooleanField( + default=None, + help_text='True if this Revenue has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.' + ) + + notes = models.TextField( + blank=True, + help_text='Economy Team notes for this revenue. Only visible to the Economy team and the submitting user.' + ) + + def clean(self): + if self.amount < 0: + raise ValidationError('Amount of a Revenue object can not be negative') + + @property + def invoice_filename(self): + return os.path.basename(self.invoice.file.name) + + @property + def approval_status(self): + if self.approved == None: + return "Pending approval" + elif self.approved == True: + return "Approved" + else: + return "Rejected" + + def approve(self, request): + """ + This method marks a revenue as approved. + Approving a revenue triggers an email to the economy system, and another email to the user who submitted the revenue + """ + if request.user == self.user: + messages.error(request, "You cannot approve your own revenues, aka. the anti-stein-bagger defense") + return + + # mark as approved and save + self.approved = True + self.save() + + # send email to economic for this revenue + send_accountingsystem_revenue_email(revenue=self) + + # send email to the user + send_revenue_approved_email(revenue=self) + + # message to the browser + messages.success(request, "Revenue %s approved" % self.pk) + + def reject(self, request): + """ + This method marks a revenue as not approved. + Not approving a revenue triggers an email to the user who submitted the revenue in the first place. + """ + # mark as not approved and save + self.approved = False + self.save() + + # send email to the user + send_revenue_rejected_email(revenue=self) + + # message to the browser + messages.success(request, "Revenue %s rejected" % self.pk) + + +class Expense(CampRelatedModel, UUIDModel): + camp = models.ForeignKey( + 'camps.Camp', + on_delete=models.PROTECT, + related_name='expenses', + help_text='The camp to which this expense belongs', + ) + + user = models.ForeignKey( + 'auth.User', + on_delete=models.PROTECT, + related_name='expenses', + help_text='The user to which this expense belongs', + ) + + amount = models.DecimalField( + decimal_places=2, + max_digits=12, + help_text='The amount of this expense in DKK. Must match the amount on the invoice uploaded below.', + ) + + description = models.CharField( + max_length=200, + help_text='A short description of this expense. Please keep it meningful as it helps the Economy team a lot when categorising expenses. 200 characters or fewer.', + ) + + paid_by_bornhack = models.BooleanField( + default=True, + help_text="Leave checked if this expense was paid by BornHack. Uncheck if you need a reimbursement for this expense.", + ) + + invoice = models.ImageField( + help_text='The invoice for this expense. Please make sure the amount on the invoice matches the amount you entered above. All common image formats are accepted.', + upload_to='expenses/', + ) + + invoice_date = models.DateField( + help_text='The invoice date for this Expense. This must match the invoice date on the documentation uploaded below.', + blank=True, + null=True, + ) + + responsible_team = models.ForeignKey( + 'teams.Team', + on_delete=models.PROTECT, + related_name='expenses', + help_text='The team to which this Expense belongs. A team responsible will need to approve the expense. When in doubt pick the Economy team.' + ) + + approved = models.NullBooleanField( + default=None, + help_text='True if this expense has been approved by the responsible team. False if it has been rejected. Blank if noone has decided yet.' + ) + + reimbursement = models.ForeignKey( + 'economy.Reimbursement', + on_delete=models.PROTECT, + related_name='expenses', + null=True, + blank=True, + help_text='The reimbursement for this expense, if any. This is a dual-purpose field. If expense.paid_by_bornhack is true then expense.reimbursement references the reimbursement which this expense is created to cover. If expense.paid_by_bornhack is false then expense.reimbursement references the reimbursement which reimbursed this expense.' + ) + + notes = models.TextField( + blank=True, + help_text='Economy Team notes for this expense. Only visible to the Economy team and the submitting user.' + ) + + def clean(self): + if self.amount < 0: + raise ValidationError('Amount of an expense can not be negative') + + @property + def invoice_filename(self): + return os.path.basename(self.invoice.file.name) + + @property + def approval_status(self): + if self.approved == None: + return "Pending approval" + elif self.approved == True: + return "Approved" + else: + return "Rejected" + + def approve(self, request): + """ + This method marks an expense as approved. + Approving an expense triggers an email to the economy system, and another email to the user who submitted the expense in the first place. + """ + if request.user == self.user: + messages.error(request, "You cannot approve your own expenses, aka. the anti-stein-bagger defense") + return + + # mark as approved and save + self.approved = True + self.save() + + # send email to economic for this expense + send_accountingsystem_expense_email(expense=self) + + # send email to the user + send_expense_approved_email(expense=self) + + # message to the browser + messages.success(request, "Expense %s approved" % self.pk) + + def reject(self, request): + """ + This method marks an expense as not approved. + Not approving an expense triggers an email to the user who submitted the expense in the first place. + """ + # mark as not approved and save + self.approved = False + self.save() + + # send email to the user + send_expense_rejected_email(expense=self) + + # message to the browser + messages.success(request, "Expense %s rejected" % self.pk) + + +class Reimbursement(CampRelatedModel, UUIDModel): + """ + A reimbursement covers one or more expenses. + """ + camp = models.ForeignKey( + 'camps.Camp', + on_delete=models.PROTECT, + related_name='reimbursements', + help_text='The camp to which this reimbursement belongs', + ) + + user = models.ForeignKey( + 'auth.User', + on_delete=models.PROTECT, + related_name='created_reimbursements', + help_text='The economy team member who created this reimbursement.' + ) + + reimbursement_user = models.ForeignKey( + 'auth.User', + on_delete=models.PROTECT, + related_name='reimbursements', + help_text='The user this reimbursement belongs to.' + ) + + notes = models.TextField( + blank=True, + help_text='Economy Team notes for this reimbursement. Only visible to the Economy team and the related user.' + ) + + paid = models.BooleanField( + default=False, + help_text="Check when this reimbursement has been paid to the user", + ) + + @property + def covered_expenses(self): + """ + Returns a queryset of all expenses covered by this reimbursement. Does not include the expense that paid for the reimbursement. + """ + return self.expenses.filter(paid_by_bornhack=False) + + @property + def amount(self): + """ + The total amount for a reimbursement is calculated by adding up the amounts for all the related expenses + """ + amount = 0 + for expense in self.expenses.filter(paid_by_bornhack=False): + amount += expense.amount + return amount + + diff --git a/src/economy/templates/dashboard.html b/src/economy/templates/dashboard.html new file mode 100644 index 00000000..98f1f16e --- /dev/null +++ b/src/economy/templates/dashboard.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block title %} +Economy | {{ block.super }} +{% endblock %} + +<{% block content %} +

Your {{ camp.title }} Economy Overview

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
WhatDescriptionActions

Expenses

You have {{ expense_count }} expense{{ expense_count|pluralize }} ({{ approved_expense_count }} approved, {{ rejected_expense_count }} rejected, and {{ unapproved_expense_count }} pending approval) for {{ camp.title }}, for a total of {{ expense_total|default:"0" }} DKK. + List Expenses + Create Expense +

Reimbursements

You have {{ reimbursement_count }} reimbursement{{ reimbursement_count|pluralize }} ({{ paid_reimbursement_count }} paid, {{ unpaid_reimbursement_count }} pending payment) for {{ camp.title }}, for a total of {{ reimbursement_total }} DKK. + List Reimbursements +

Revenue

You have {{ revenue_count }} revenue{{ revenue_count|pluralize }} ({{ approved_revenue_count }} approved, {{ rejected_revenue_count }} rejected, and {{ unapproved_revenue_count }} still pending approval) for {{ camp.title }}, for a total of {{ revenue_total|default:"0" }} DKK. + List Revenues + Create Revenue +
+{% endblock %} diff --git a/src/economy/templates/emails/accountingsystem_expense_email.txt b/src/economy/templates/emails/accountingsystem_expense_email.txt new file mode 100644 index 00000000..22688461 --- /dev/null +++ b/src/economy/templates/emails/accountingsystem_expense_email.txt @@ -0,0 +1,10 @@ +New expense for {{ expense.camp }} + +The attached receipt for expense {{ expense.pk }} has the following description: + +{{ expense.description }} + +Greetings + +The {{ expense.camp.title }} Economy Team + diff --git a/src/economy/templates/emails/accountingsystem_revenue_email.txt b/src/economy/templates/emails/accountingsystem_revenue_email.txt new file mode 100644 index 00000000..f666ec71 --- /dev/null +++ b/src/economy/templates/emails/accountingsystem_revenue_email.txt @@ -0,0 +1,10 @@ +New revenue for {{ revenue.camp }} + +The attached receipt for revenue {{ revenue.pk }} has the following description: + +{{ revenue.description }} + +Greetings + +The {{ revenue.camp.title }} Economy Team + diff --git a/src/economy/templates/emails/expense_approved_email.txt b/src/economy/templates/emails/expense_approved_email.txt new file mode 100644 index 00000000..5f23d887 --- /dev/null +++ b/src/economy/templates/emails/expense_approved_email.txt @@ -0,0 +1,12 @@ +Hi, + +Your expense {{ expense.pk }} for {{ expense.camp.title }} has been approved. The amount is DKK {{ expense.amount }} and description of the expense is: + +{{ expense.description }} + +{% if not expense.paid_by_bornhack %}The money will be transferred to your bank account with the next batch of reimbursements.{% else %}As this expense was paid for by BornHack no further action will be taken.{% endif %} + +Have a nice day! + +The {{ expense.camp.title }} Economy Team + diff --git a/src/economy/templates/emails/expense_awaiting_approval_email.txt b/src/economy/templates/emails/expense_awaiting_approval_email.txt new file mode 100644 index 00000000..70d0cea0 --- /dev/null +++ b/src/economy/templates/emails/expense_awaiting_approval_email.txt @@ -0,0 +1,12 @@ +Hi, + +A new expense {{ expense.pk }} for {{ expense.responsible_team.name }} Team was just submitted by user {{ expense.user }}. The amount is DKK {{ expense.amount }} and description of the expense is: + +{{ expense.description }} + +{% if expense.paid_by_bornhack %}The expense was paid for by BornHack{% else %}The expense was paid for by the user "{{ expense.user }}" so it will need to be reimbursed after approval.{% endif %} + +Have a nice day! + +The {{ expense.camp.title }} Team + diff --git a/src/economy/templates/emails/expense_rejected_email.txt b/src/economy/templates/emails/expense_rejected_email.txt new file mode 100644 index 00000000..fb4783ef --- /dev/null +++ b/src/economy/templates/emails/expense_rejected_email.txt @@ -0,0 +1,12 @@ +Hi, + +Your expense {{ expense.pk }} for {{ expense.camp.title }} has been rejected. The amount is DKK {{ expense.amount }} and description of the expense is: + +{{ expense.description }} + +Please contact us for more info. + +Have a nice day! + +The {{ expense.camp.title }} Economy Team + diff --git a/src/economy/templates/emails/revenue_approved_email.txt b/src/economy/templates/emails/revenue_approved_email.txt new file mode 100644 index 00000000..6aee7c5c --- /dev/null +++ b/src/economy/templates/emails/revenue_approved_email.txt @@ -0,0 +1,10 @@ +Hi, + +Your revenue {{ revenue.pk }} for {{ revenue.camp.title }} has been approved. The amount is DKK {{ revenue.amount }} and description of the revenue is: + +{{ revenue.description }} + +Have a nice day! + +The {{ revenue.camp.title }} Economy Team + diff --git a/src/economy/templates/emails/revenue_awaiting_approval_email.txt b/src/economy/templates/emails/revenue_awaiting_approval_email.txt new file mode 100644 index 00000000..9b78bb01 --- /dev/null +++ b/src/economy/templates/emails/revenue_awaiting_approval_email.txt @@ -0,0 +1,10 @@ +Hi, + +A new revenue {{ revenue.pk }} for {{ revenue.responsible_team.name }} Team was just submitted by user {{ revenue.user }}. The amount is DKK {{ revenue.amount }} and description of the revenue is: + +{{ revenue.description }} + +Have a nice day! + +The {{ revenue.camp.title }} Team + diff --git a/src/economy/templates/emails/revenue_rejected_email.txt b/src/economy/templates/emails/revenue_rejected_email.txt new file mode 100644 index 00000000..65c567b8 --- /dev/null +++ b/src/economy/templates/emails/revenue_rejected_email.txt @@ -0,0 +1,12 @@ +Hi, + +Your revenue {{ revenue.pk }} for {{ revenue.camp.title }} has been rejected. The amount is DKK {{ revenue.amount }} and description of the revenue is: + +{{ revenue.description }} + +Please contact us for more info. + +Have a nice day! + +The {{ revenue.camp.title }} Economy Team + diff --git a/src/economy/templates/expense_delete.html b/src/economy/templates/expense_delete.html new file mode 100644 index 00000000..df1e30e3 --- /dev/null +++ b/src/economy/templates/expense_delete.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +Delete Expense | {{ block.super }} +{% endblock %} + +{% block content %} +

Really delete expense {{ expense.uuid }}?

+{% include 'includes/expense_detail_panel.html' %} +
+ {% csrf_token %} + + Cancel +
+{% endblock %} diff --git a/src/economy/templates/expense_detail.html b/src/economy/templates/expense_detail.html new file mode 100644 index 00000000..ac232641 --- /dev/null +++ b/src/economy/templates/expense_detail.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %} +Expense Details | {{ block.super }} +{% endblock %} + +{% block content %} +
+{% include 'includes/expense_detail_panel.html' %} +
+Back to Expense List +{% endblock %} diff --git a/src/economy/templates/expense_form.html b/src/economy/templates/expense_form.html new file mode 100644 index 00000000..420bd999 --- /dev/null +++ b/src/economy/templates/expense_form.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +{% if object %}Update{% else %}Create{% endif %} Expense | {{ block.super }} +{% endblock %} + +{% block content %} +

{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Expense

+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel +
+{% endblock %} diff --git a/src/economy/templates/expense_list.html b/src/economy/templates/expense_list.html new file mode 100644 index 00000000..15d62c80 --- /dev/null +++ b/src/economy/templates/expense_list.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block title %} +Expenses | {{ block.super }} +{% endblock %} + +{% block extra_head %} + + +{% endblock extra_head %} + +<{% block content %} +

Your {{ camp.title }} Expenses

+ +{% include 'includes/expense_list_panel.html' %} + +{% if perms.camps.expense_create_permission %} + Create Expense +{% else %} +

You don't have permission to add expenses. Please ask someone from the Economy team to add the permission if you need it.

+{% endif %} + +{% endblock %} diff --git a/src/economy/templates/includes/expense_detail_panel.html b/src/economy/templates/includes/expense_detail_panel.html new file mode 100644 index 00000000..d83adb44 --- /dev/null +++ b/src/economy/templates/includes/expense_detail_panel.html @@ -0,0 +1,62 @@ +
+
Expense Details for {{ expense.pk }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if not expense.paid_by_bornhack %} + + + + {% endif %} + + + + + + + + +
Created By{{ expense.user }}
Amount{{ expense.amount }} DKK
Invoice Date{{ expense.invoice_date }}
Description{{ expense.description }}
Paid by BornHack?This expense was paid by {% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}, and will be reimbursed when approved.{% endif %}
Filename{{ expense.invoice }}
Approved?{{ expense.approval_status }}
Reimbursement? + {% if expense.reimbursement %} + {% if request.resolver_match.app_name == "backoffice" %} + {{ expense.reimbursement.pk }} + {% else %} + {{ expense.reimbursement.pk }} + {% endif %} + {% else %} + N/A + {% endif %} +
Invoice +
+ Filename: {{ expense.invoice_filename }} +
Economy Team Notes{{ expense.notes|default:"N/A" }}
+
+
+ diff --git a/src/economy/templates/includes/expense_list_panel.html b/src/economy/templates/includes/expense_list_panel.html new file mode 100644 index 00000000..aca86383 --- /dev/null +++ b/src/economy/templates/includes/expense_list_panel.html @@ -0,0 +1,63 @@ +{% if expense_list %} + + + + + {% if not reimbursement %} + + {% endif %} + + + + + {% if not reimbursement %} + + + {% endif %} + + + + + {% for expense in expense_list %} + + + {% if not reimbursement %} + + {% endif %} + + + + + + {% if not reimbursement %} + + + {% endif %} + + + + {% endfor %} + +
Created ByPaid byAmountInvoice DateDescriptionResponsible TeamApprovedReimbursementActions
{{ expense.user }}{% if expense.paid_by_bornhack %}BornHack{% else %}{{ expense.user }}{% endif %}{{ expense.amount }} DKK{{ expense.invoice_date }}{{ expense.description }}{{ expense.responsible_team.name }} Team{{ expense.approval_status }} + {% if expense.reimbursement and not expense.paid_by_bornhack %} + {% if request.resolver_match.app_name == "backoffice" %} + Details + {% else %} + Details + {% endif %} + {% else %} + N/A + {% endif %} + + {% if request.resolver_match.app_name == "backoffice" %} + Details + {% else %} + Details + Update + Delete + {% endif %} +
+{% else %} +

No expenses found.

+{% endif %} + diff --git a/src/economy/templates/includes/reimbursement_detail_panel.html b/src/economy/templates/includes/reimbursement_detail_panel.html new file mode 100644 index 00000000..e242435d --- /dev/null +++ b/src/economy/templates/includes/reimbursement_detail_panel.html @@ -0,0 +1,37 @@ +
+
Reimbursement Details for {{ reimbursement.pk }} for {{ reimbursement.reimbursement_user }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Reimbursement User{{ reimbursement.reimbursement_user }}
Created By Economy Team Member{{ reimbursement.user }}
Total Amount{{ reimbursement.amount }} DKK
Economy Team Notes{{ reimbursement.notes|default:"N/A" }}
Paid{{ reimbursement.paid }}
Created{{ reimbursement.created }} by {{ reimbursement.user }}
Expenses covered by this Reimbursement + {% include 'includes/expense_list_panel.html' with expense_list=reimbursement.covered_expenses.all %} +
+
+
diff --git a/src/economy/templates/includes/reimbursement_list_panel.html b/src/economy/templates/includes/reimbursement_list_panel.html new file mode 100644 index 00000000..643f9449 --- /dev/null +++ b/src/economy/templates/includes/reimbursement_list_panel.html @@ -0,0 +1,41 @@ +{% if reimbursement_list %} + + + + + + + + + + + + + + {% for reim in reimbursement_list %} + + + + + + + + + + {% endfor %} + +
CampCreated ByCreated ForEconomy Team NotesAmountPaidActions
{{ reim.camp }}{{ reim.user }}{{ reim.reimbursement_user }}{{ reim.notes|default:"N/A" }}{{ reim.amount }} DKK{{ reim.paid }} + {% if request.resolver_match.app_name == "backoffice" %} + Details + Update + {% if not reim.paid %} + Delete + {% endif %} + {% else %} + Details + {% endif %} +
+{% else %} +

No reimbursements found for {{ camp.title }}

+{% endif %} + diff --git a/src/economy/templates/includes/revenue_detail_panel.html b/src/economy/templates/includes/revenue_detail_panel.html new file mode 100644 index 00000000..88ceb857 --- /dev/null +++ b/src/economy/templates/includes/revenue_detail_panel.html @@ -0,0 +1,43 @@ +
+
Revenue Details for {{ revenue.pk }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Created By{{ revenue.user }}
Amount{{ revenue.amount }} DKK
Invoice Date{{ revenue.invoice_date }}
Description{{ revenue.description }}
Filename{{ revenue.invoice }}
Approved?{{ revenue.approval_status }}
Invoice +
+ Filename: {{ revenue.invoice_filename }} +
Economy Team Notes{{ revenue.notes|default:"N/A" }}
+
+
+ diff --git a/src/economy/templates/includes/revenue_list_panel.html b/src/economy/templates/includes/revenue_list_panel.html new file mode 100644 index 00000000..a2165ac1 --- /dev/null +++ b/src/economy/templates/includes/revenue_list_panel.html @@ -0,0 +1,39 @@ +{% if revenue_list %} + + + + + + + + + + + + + + {% for revenue in revenue_list %} + + + + + + + + + + {% endfor %} + +
Created ByAmountInvoice DateDescriptionResponsible TeamApprovedActions
{{ revenue.user }}{{ revenue.amount }} DKK{{ revenue.invoice_date }}{{ revenue.description }}{{ revenue.responsible_team.name }} Team{{ revenue.approval_status }} + {% if request.resolver_match.app_name == "backoffice" %} + Details + {% else %} + Details + Update + Delete + {% endif %} +
+{% else %} +

No revenues found.

+{% endif %} + diff --git a/src/economy/templates/reimbursement_detail.html b/src/economy/templates/reimbursement_detail.html new file mode 100644 index 00000000..d1d7b03e --- /dev/null +++ b/src/economy/templates/reimbursement_detail.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +Reimbursement Details | {{ block.super }} +{% endblock %} + +{% block content %} +

Reimbursement Details

+ +{% include 'includes/reimbursement_detail_panel.html' %} + +Back to list + +{% endblock content %} + diff --git a/src/economy/templates/reimbursement_list.html b/src/economy/templates/reimbursement_list.html new file mode 100644 index 00000000..823a824e --- /dev/null +++ b/src/economy/templates/reimbursement_list.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block title %} +Expenses | {{ block.super }} +{% endblock %} + +{% block extra_head %} + + +{% endblock extra_head %} + +<{% block content %} +

Your {{ camp.title }} Reimbursements

+ +{% include 'includes/reimbursement_list_panel.html' %} + +{% endblock %} diff --git a/src/economy/templates/revenue_delete.html b/src/economy/templates/revenue_delete.html new file mode 100644 index 00000000..234711aa --- /dev/null +++ b/src/economy/templates/revenue_delete.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +Delete Revenue | {{ block.super }} +{% endblock %} + +{% block content %} +

Really delete revenue {{ revenue.uuid }}?

+{% include 'includes/revenue_detail_panel.html' %} +
+ {% csrf_token %} + + Cancel +
+{% endblock %} diff --git a/src/economy/templates/revenue_detail.html b/src/economy/templates/revenue_detail.html new file mode 100644 index 00000000..fc8dd0db --- /dev/null +++ b/src/economy/templates/revenue_detail.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %} +Revenue Details | {{ block.super }} +{% endblock %} + +{% block content %} +
+{% include 'includes/revenue_detail_panel.html' %} +
+Back to Revenue List +{% endblock %} diff --git a/src/economy/templates/revenue_form.html b/src/economy/templates/revenue_form.html new file mode 100644 index 00000000..cf8245d0 --- /dev/null +++ b/src/economy/templates/revenue_form.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block title %} +{% if object %}Update{% else %}Create{% endif %} Revenue | {{ block.super }} +{% endblock %} + +{% block content %} +

{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Revenue

+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel +
+{% endblock %} diff --git a/src/economy/templates/revenue_list.html b/src/economy/templates/revenue_list.html new file mode 100644 index 00000000..bfc6737d --- /dev/null +++ b/src/economy/templates/revenue_list.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load staticfiles %} + +{% block title %} +Revenues | {{ block.super }} +{% endblock %} + +{% block extra_head %} + + +{% endblock extra_head %} + +<{% block content %} +

Your {{ camp.title }} Revenues

+ +{% include 'includes/revenue_list_panel.html' %} + +{% if perms.camps.revenue_create_permission %} + Create Revenue +{% else %} +

You don't have permission to add revenue. Please ask someone from the Economy team to add the permission if you need it.

+{% endif %} + +{% endblock %} diff --git a/src/economy/urls.py b/src/economy/urls.py new file mode 100644 index 00000000..5ee3e1d5 --- /dev/null +++ b/src/economy/urls.py @@ -0,0 +1,114 @@ +from django.urls import path, include +from .views import * + +app_name = 'economy' + +urlpatterns = [ + path( + '', + EconomyDashboardView.as_view(), + name='dashboard' + ), + + # expenses + path( + 'expenses/', + include([ + path( + '', + ExpenseListView.as_view(), + name='expense_list' + ), + path( + 'add/', + ExpenseCreateView.as_view(), + name='expense_create' + ), + path( + '/', + include([ + path( + '', + ExpenseDetailView.as_view(), + name='expense_detail' + ), + path( + 'update/', + ExpenseUpdateView.as_view(), + name='expense_update' + ), + path( + 'delete/', + ExpenseDeleteView.as_view(), + name='expense_delete' + ), + path( + 'invoice/', + ExpenseInvoiceView.as_view(), + name='expense_invoice' + ), + ]), + ), + ]), + ), + + # reimbursements + path( + 'reimbursements/', + include([ + path( + '', + ReimbursementListView.as_view(), + name='reimbursement_list' + ), + path( + '/', + ReimbursementDetailView.as_view(), + name='reimbursement_detail' + ), + ]), + ), + + # revenue + path( + 'revenues/', + include([ + path( + '', + RevenueListView.as_view(), + name='revenue_list' + ), + path( + 'add/', + RevenueCreateView.as_view(), + name='revenue_create' + ), + path( + '/', + include([ + path( + '', + RevenueDetailView.as_view(), + name='revenue_detail' + ), + path( + 'update/', + RevenueUpdateView.as_view(), + name='revenue_update' + ), + path( + 'delete/', + RevenueDeleteView.as_view(), + name='revenue_delete' + ), + path( + 'invoice/', + RevenueInvoiceView.as_view(), + name='revenue_invoice' + ), + ]), + ), + ]), + ), +] + diff --git a/src/economy/views.py b/src/economy/views.py new file mode 100644 index 00000000..cfac40e0 --- /dev/null +++ b/src/economy/views.py @@ -0,0 +1,317 @@ +import os, magic + +from django.shortcuts import render, redirect +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect, HttpResponse, Http404 +from django.urls import reverse +from django.views.generic import CreateView, ListView, DetailView, TemplateView, UpdateView, DeleteView +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Sum + +from camps.mixins import CampViewMixin +from utils.email import add_outgoing_email +from utils.mixins import RaisePermissionRequiredMixin +from teams.models import Team +from .models import * +from .mixins import * +from .forms import * + + +class EconomyDashboardView(LoginRequiredMixin, CampViewMixin, TemplateView): + template_name = 'dashboard.html' + + def get_context_data(self, **kwargs): + """ + Add expenses, reimbursements and revenues to the context + """ + context = super().get_context_data(**kwargs) + + # get reimbursement stats + context['reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user, camp=self.camp).count() + context['unpaid_reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user, paid=False, camp=self.camp).count() + context['paid_reimbursement_count'] = Reimbursement.objects.filter(reimbursement_user=self.request.user, paid=True, camp=self.camp).count() + reimbursement_total = 0 + for reimbursement in Reimbursement.objects.filter(reimbursement_user=self.request.user, camp=self.camp): + reimbursement_total += reimbursement.amount + context['reimbursement_total'] = reimbursement_total + + # get expense stats + context['expense_count'] = Expense.objects.filter(user=self.request.user, camp=self.camp).count() + context['unapproved_expense_count'] = Expense.objects.filter(user=self.request.user, approved__isnull=True, camp=self.camp).count() + context['approved_expense_count'] = Expense.objects.filter(user=self.request.user, approved=True, camp=self.camp).count() + context['rejected_expense_count'] = Expense.objects.filter(user=self.request.user, approved=False, camp=self.camp).count() + context['expense_total'] = Expense.objects.filter(user=self.request.user, camp=self.camp).aggregate(Sum('amount'))['amount__sum'] + + # get revenue stats + context['revenue_count'] = Revenue.objects.filter(user=self.request.user, camp=self.camp).count() + context['unapproved_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved__isnull=True, camp=self.camp).count() + context['approved_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved=True, camp=self.camp).count() + context['rejected_revenue_count'] = Revenue.objects.filter(user=self.request.user, approved=False, camp=self.camp).count() + context['revenue_total'] = Revenue.objects.filter(user=self.request.user, camp=self.camp).aggregate(Sum('amount'))['amount__sum'] + + return context + + +########### Expense related views ############### + +class ExpenseListView(LoginRequiredMixin, CampViewMixin, ListView): + model = Expense + template_name = 'expense_list.html' + + def get_queryset(self): + # only return Expenses belonging to the current user + return super().get_queryset().filter(user=self.request.user) + + +class ExpenseDetailView(CampViewMixin, ExpensePermissionMixin, DetailView): + model = Expense + template_name = 'expense_detail.html' + + +class ExpenseCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView): + model = Expense + template_name = 'expense_form.html' + permission_required = ("camps.expense_create_permission") + form_class = ExpenseCreateForm + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp) + return context + + def form_valid(self, form): + expense = form.save(commit=False) + expense.user = self.request.user + expense.camp = self.camp + expense.save() + + # a message for the user + messages.success( + self.request, + "The expense has been saved. It is now awaiting approval by the economy team.", + ) + + # send an email to the economy team + add_outgoing_email( + "emails/expense_awaiting_approval_email.txt", + formatdict=dict(expense=expense), + subject="New %s expense for %s Team is awaiting approval" % (expense.camp.title, expense.responsible_team.name), + to_recipients=[settings.ECONOMYTEAM_EMAIL], + ) + + # return to the expense list page + return HttpResponseRedirect(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug})) + + +class ExpenseUpdateView(CampViewMixin, ExpensePermissionMixin, UpdateView): + model = Expense + template_name = 'expense_form.html' + form_class = ExpenseUpdateForm + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + if self.get_object().approved: + messages.error(self.request, "This expense has already been approved, it cannot be updated") + return redirect(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug})) + return response + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp) + return context + + def get_success_url(self): + messages.success(self.request, "Expense %s has been updated" % self.get_object().pk) + return(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug})) + + +class ExpenseDeleteView(CampViewMixin, ExpensePermissionMixin, UpdateView): + model = Expense + template_name = 'expense_delete.html' + fields = [] + + def form_valid(self, form): + expense = self.get_object() + if expense.approved: + messages.error(self.request, "This expense has already been approved, it cannot be deleted") + else: + message = "Expense %s has been deleted" % expense.pk + expense.delete() + messages.success(self.request, message) + return redirect(self.get_success_url()) + + def get_success_url(self): + return(reverse('economy:expense_list', kwargs={'camp_slug': self.camp.slug})) + + +class ExpenseInvoiceView(CampViewMixin, ExpensePermissionMixin, DetailView): + """ + This view returns the invoice for an Expense with the proper mimetype + Uses ExpensePermissionMixin to make sure the user is allowed to see the image + """ + model = Expense + + def get(self, request, *args, **kwargs): + # get expense + expense = self.get_object() + # read invoice file + invoicedata = expense.invoice.read() + # find mimetype + mimetype = magic.from_buffer(invoicedata, mime=True) + # check if we have a PDF, no preview if so, load a pdf icon instead + if mimetype=="application/pdf" and 'preview' in request.GET: + invoicedata = open(os.path.join(settings.DJANGO_BASE_PATH, "static_src/img/pdf.png"), "rb").read() + mimetype = magic.from_buffer(invoicedata, mime=True) + # put the response together and return it + response = HttpResponse(content_type=mimetype) + response.write(invoicedata) + return response + + +########### Reimbursement related views ############### + + +class ReimbursementListView(LoginRequiredMixin, CampViewMixin, ListView): + model = Reimbursement + template_name = 'reimbursement_list.html' + + def get_queryset(self): + # only return Expenses belonging to the current user + return super().get_queryset().filter(reimbursement_user=self.request.user) + + +class ReimbursementDetailView(CampViewMixin, ReimbursementPermissionMixin, DetailView): + model = Reimbursement + template_name = 'reimbursement_detail.html' + + +########### Revenue related views ############### + + +class RevenueListView(LoginRequiredMixin, CampViewMixin, ListView): + model = Revenue + template_name = 'revenue_list.html' + + def get_queryset(self): + # only return Revenues belonging to the current user + return super().get_queryset().filter(user=self.request.user) + + +class RevenueDetailView(CampViewMixin, RevenuePermissionMixin, DetailView): + model = Revenue + template_name = 'revenue_detail.html' + + +class RevenueCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView): + model = Revenue + template_name = 'revenue_form.html' + permission_required = ("camps.revenue_create_permission") + form_class = RevenueCreateForm + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp) + return context + + def form_valid(self, form): + revenue = form.save(commit=False) + revenue.user = self.request.user + revenue.camp = self.camp + revenue.save() + + # a message for the user + messages.success( + self.request, + "The revenue has been saved. It is now awaiting approval by the economy team.", + ) + + # send an email to the economy team + add_outgoing_email( + "emails/revenue_awaiting_approval_email.txt", + formatdict=dict(revenue=revenue), + subject="New %s revenue for %s Team is awaiting approval" % (revenue.camp.title, revenue.responsible_team.name), + to_recipients=[settings.ECONOMYTEAM_EMAIL], + ) + + # return to the revenue list page + return HttpResponseRedirect(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug})) + + +class RevenueUpdateView(CampViewMixin, RevenuePermissionMixin, UpdateView): + model = Revenue + template_name = 'revenue_form.html' + form_class = RevenueUpdateForm + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + if self.get_object().approved: + messages.error(self.request, "This revenue has already been approved, it cannot be updated") + return redirect(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug})) + return response + + def get_context_data(self, **kwargs): + """ + Do not show teams that are not part of the current camp in the dropdown + """ + context = super().get_context_data(**kwargs) + context['form'].fields['responsible_team'].queryset = Team.objects.filter(camp=self.camp) + return context + + def get_success_url(self): + messages.success(self.request, "Revenue %s has been updated" % self.get_object().pk) + return(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug})) + + +class RevenueDeleteView(CampViewMixin, RevenuePermissionMixin, UpdateView): + model = Revenue + template_name = 'revenue_delete.html' + fields = [] + + def form_valid(self, form): + revenue = self.get_object() + if revenue.approved: + messages.error(self.request, "This revenue has already been approved, it cannot be deleted") + else: + message = "Revenue %s has been deleted" % revenue.pk + revenue.delete() + messages.success(self.request, message) + return redirect(self.get_success_url()) + + def get_success_url(self): + return(reverse('economy:revenue_list', kwargs={'camp_slug': self.camp.slug})) + + +class RevenueInvoiceView(CampViewMixin, RevenuePermissionMixin, DetailView): + """ + This view returns a http response with the invoice for a Revenue object, with the proper mimetype + Uses RevenuePermissionMixin to make sure the user is allowed to see the file + """ + model = Revenue + + def get(self, request, *args, **kwargs): + # get revenue + revenue = self.get_object() + # read invoice file + invoicedata = revenue.invoice.read() + # find mimetype + mimetype = magic.from_buffer(invoicedata, mime=True) + # check if we have a PDF, no preview if so, load a pdf icon instead + if mimetype=="application/pdf" and 'preview' in request.GET: + invoicedata = open(os.path.join(settings.DJANGO_BASE_PATH, "static_src/img/pdf.png"), "rb").read() + mimetype = magic.from_buffer(invoicedata, mime=True) + # put the response together and return it + response = HttpResponse(content_type=mimetype) + response.write(invoicedata) + return response + diff --git a/src/events/__init__.py b/src/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/events/admin.py b/src/events/admin.py new file mode 100644 index 00000000..b0d4fd2d --- /dev/null +++ b/src/events/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import Type, Routing + +@admin.register(Type) +class TypeAdmin(admin.ModelAdmin): + pass + +@admin.register(Routing) +class RoutingAdmin(admin.ModelAdmin): + pass + + diff --git a/src/events/apps.py b/src/events/apps.py new file mode 100644 index 00000000..38546443 --- /dev/null +++ b/src/events/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + name = 'events' diff --git a/src/events/handler.py b/src/events/handler.py new file mode 100644 index 00000000..ab1e0cd5 --- /dev/null +++ b/src/events/handler.py @@ -0,0 +1,81 @@ +from django.utils import timezone +from datetime import timedelta +from ircbot.utils import add_irc_message +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def handle_team_event(eventtype, irc_message=None, irc_timeout=60, email_template=None, email_formatdict=None): + """ + This method is our basic event handler. + The type of event determines which teams receive notifications. + TODO: Add some sort of priority to messages + """ + #logger.info("Inside handle_team_event, eventtype %s" % eventtype) + + # get event type from database + from .models import Type + try: + eventtype = Type.objects.get(name=eventtype) + except Type.DoesNotExist: + # unknown event type, do nothing + logger.error("Unknown eventtype %s" % eventtype) + return + + if not eventtype.teams: + # no routes found for this eventtype, do nothing + #logger.error("No routes round for eventtype %s" % eventtype) + return + + # loop over routes (teams) for this eventtype + for team in eventtype.teams: + logger.info("Handling eventtype %s for team %s" % (eventtype, team)) + team_irc_notification(team=team, eventtype=eventtype, irc_message=irc_message, irc_timeout=irc_timeout) + team_email_notification(team=team, eventtype=eventtype, email_template=None, email_formatdict=None) + # handle any future notification types here.. + + +def team_irc_notification(team, eventtype, irc_message=None, irc_timeout=60): + """ + Sends IRC notifications for events to team IRC channels + """ + logger.info("Inside team_irc_notification, message %s" % irc_message) + if not irc_message: + logger.error("No IRC message found") + return + + if not eventtype.irc_notification: + logger.error("IRC notifications not enabled for eventtype %s" % eventtype) + return + + if not team.private_irc_channel_name or not team.private_irc_channel_bot: + logger.error("team %s does not have a private IRC channel" % team) + return + + # send an IRC message to the the channel for this team + add_irc_message( + target=team.private_irc_channel_name, + message=irc_message, + timeout=60 + ) + logger.info("Added new IRC message for channel %s" % team.irc_channel_name) + + +def team_email_notification(team, eventtype, email_template=None, email_formatdict=None): + """ + Sends email notifications for events to team mailinglists (if possible, + otherwise directly to the team responsibles) + """ + if not email_template or not email_formatdict or not eventtype.email_notification: + # no email message found, or email notifications are not enabled for this event type + return + + if team.mailing_list: + # send notification to the team mailing list + recipient_list = [team.mailing_list] + else: + # no team mailinglist, send to the team responsibles instead + recipient_list = [resp.email for resp in team.responsible_members.all()] + + # TODO: actually send the email here + diff --git a/src/events/migrations/0001_initial.py b/src/events/migrations/0001_initial.py new file mode 100644 index 00000000..3c3c00f8 --- /dev/null +++ b/src/events/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 13:16 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('teams', '0025_auto_20180318_1318'), + ] + + operations = [ + migrations.CreateModel( + name='Routing', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Type', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.TextField(help_text='The type of event', unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='routing', + name='eventtype', + field=models.ForeignKey(help_text='The type of event to route', on_delete=django.db.models.deletion.PROTECT, related_name='eventroutes', to='events.Type'), + ), + migrations.AddField( + model_name='routing', + name='team', + field=models.ForeignKey(help_text='The team which should receive events of this type.', on_delete=django.db.models.deletion.PROTECT, related_name='eventroutes', to='teams.Team'), + ), + ] diff --git a/src/events/migrations/0002_create_eventtype.py b/src/events/migrations/0002_create_eventtype.py new file mode 100644 index 00000000..a84eb0ef --- /dev/null +++ b/src/events/migrations/0002_create_eventtype.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 13:18 +from __future__ import unicode_literals + +from django.db import migrations + +def create_eventtypes(apps, schema_editor): + Type = apps.get_model('events', 'Type') + Type.objects.create(name='public_credit_name_changed') + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_eventtypes), + ] + diff --git a/src/events/migrations/0003_create_another_eventtype.py b/src/events/migrations/0003_create_another_eventtype.py new file mode 100644 index 00000000..1259799c --- /dev/null +++ b/src/events/migrations/0003_create_another_eventtype.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-25 14:16 +from __future__ import unicode_literals + +from django.db import migrations + +def create_eventtype(apps, schema_editor): + Type = apps.get_model('events', 'Type') + Type.objects.create(name='ticket_created') + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0002_create_eventtype'), + ] + + operations = [ + migrations.RunPython(create_eventtype), + ] diff --git a/src/events/migrations/0004_auto_20180403_1228.py b/src/events/migrations/0004_auto_20180403_1228.py new file mode 100644 index 00000000..23fe4e15 --- /dev/null +++ b/src/events/migrations/0004_auto_20180403_1228.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-03 10:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_create_another_eventtype'), + ] + + operations = [ + migrations.AddField( + model_name='type', + name='email_notification', + field=models.BooleanField(default=False, help_text='Check to send email notifications for this type of event.'), + ), + migrations.AddField( + model_name='type', + name='irc_notification', + field=models.BooleanField(default=False, help_text='Check to send IRC notifications for this type of event.'), + ), + ] diff --git a/src/events/migrations/__init__.py b/src/events/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/events/models.py b/src/events/models.py new file mode 100644 index 00000000..74ba03a8 --- /dev/null +++ b/src/events/models.py @@ -0,0 +1,61 @@ +from django.db import models +from utils.models import CreatedUpdatedModel +from teams.models import Team + +class Type(CreatedUpdatedModel): + """ + The events.Type model contains different types of system events which can happen. + New event types should be added in data migrations. + The following types are currently used in the codebase: + - ticket_created: Whenever a new ShopTicket is created + - public_credit_name_changed: Whenever a user changes public_credit_name in the profile + """ + name = models.TextField( + unique=True, + help_text='The type of event' + ) + + irc_notification = models.BooleanField( + default=False, + help_text='Check to send IRC notifications for this type of event.', + ) + + email_notification = models.BooleanField( + default=False, + help_text='Check to send email notifications for this type of event.', + ) + def __str__(self): + return self.name + + @property + def teams(self): + """ + This property returns a queryset with all the teams that should receive this type of events + """ + team_ids = Routing.objects.filter(eventtype=self).values_list('team', flat=True) + return Team.objects.filter(pk__in=team_ids) + + +class Routing(CreatedUpdatedModel): + """ + The events.Routing model contains routings for system events. + Add a new entry to route events of a certain type to a team. + Several teams can receive the same type of event. + """ + eventtype = models.ForeignKey( + 'events.Type', + related_name='eventroutes', + on_delete=models.PROTECT, + help_text='The type of event to route', + ) + + team = models.ForeignKey( + 'teams.Team', + related_name='eventroutes', + on_delete=models.PROTECT, + help_text='The team which should receive events of this type.' + ) + + def __str__(self): + return "%s -> %s" % (self.eventtype, self.team) + diff --git a/src/info/tests.py b/src/events/tests.py similarity index 100% rename from src/info/tests.py rename to src/events/tests.py diff --git a/src/ircbot/views.py b/src/events/views.py similarity index 100% rename from src/ircbot/views.py rename to src/events/views.py diff --git a/src/feedback/__init__.py b/src/feedback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/feedback/admin.py b/src/feedback/admin.py new file mode 100644 index 00000000..2f87a6ea --- /dev/null +++ b/src/feedback/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Feedback + + +@admin.register(Feedback) +class FeedbackAdmin(admin.ModelAdmin): + list_display = ('user', 'camp', 'feedback') diff --git a/src/feedback/apps.py b/src/feedback/apps.py new file mode 100644 index 00000000..63cb2219 --- /dev/null +++ b/src/feedback/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + name = 'feedback' diff --git a/src/feedback/migrations/0001_initial.py b/src/feedback/migrations/0001_initial.py new file mode 100644 index 00000000..4e401ab1 --- /dev/null +++ b/src/feedback/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1 on 2018-08-20 13:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Feedback', + 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)), + ('feedback', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/feedback/migrations/0002_feedback_camp.py b/src/feedback/migrations/0002_feedback_camp.py new file mode 100644 index 00000000..19e2dfae --- /dev/null +++ b/src/feedback/migrations/0002_feedback_camp.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-20 13:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0030_camp_light_text'), + ('feedback', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='feedback', + name='camp', + field=models.ForeignKey(default='30fd754f-dae4-460f-8128-6638fb29ab2d', on_delete=django.db.models.deletion.PROTECT, to='camps.Camp'), + preserve_default=False, + ), + ] diff --git a/src/feedback/migrations/__init__.py b/src/feedback/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/feedback/models.py b/src/feedback/models.py new file mode 100644 index 00000000..611dc25b --- /dev/null +++ b/src/feedback/models.py @@ -0,0 +1,9 @@ +from django.db import models + +from utils.models import UUIDModel, CreatedUpdatedModel, CampRelatedModel + + +class Feedback(CampRelatedModel, UUIDModel): + camp = models.ForeignKey('camps.Camp', on_delete=models.PROTECT) + user = models.ForeignKey('auth.User', on_delete=models.PROTECT) + feedback = models.TextField() diff --git a/src/feedback/templates/feedback/feedback_form.html b/src/feedback/templates/feedback/feedback_form.html new file mode 100644 index 00000000..6ee63271 --- /dev/null +++ b/src/feedback/templates/feedback/feedback_form.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% load commonmark %} +{% load bootstrap3 %} + +{% block content %} + + + +
+

+ BornHack can always improve, but we need your feedback to know how. So write you thoughts on what was good and what wasn't, it's highly appreciated! +

+
+ +
+
+ {% csrf_token %} + {% bootstrap_form form %} + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/src/news/tests.py b/src/feedback/tests.py similarity index 100% rename from src/news/tests.py rename to src/feedback/tests.py diff --git a/src/feedback/views.py b/src/feedback/views.py new file mode 100644 index 00000000..750356b8 --- /dev/null +++ b/src/feedback/views.py @@ -0,0 +1,33 @@ +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.views.generic import CreateView + +from camps.mixins import CampViewMixin +from tokens.models import Token +from .models import Feedback + + +class FeedbackCreate(LoginRequiredMixin, CampViewMixin, CreateView): + model = Feedback + fields = ['feedback'] + + def form_valid(self, form): + feedback = form.save(commit=False) + feedback.user = self.request.user + feedback.camp = self.camp + feedback.save() + thanks_message = "Thank you! Your feedback is highly appreciated!" + try: + token = Token.objects.get( + camp=self.camp, + description="Feedback thanks" + ) + thanks_message += " And for your efforts, here is a token: {}".format(token.token) + except Token.DoesNotExist: + pass + + messages.success(self.request, thanks_message) + + return HttpResponseRedirect(reverse("feedback", kwargs={"camp_slug": self.camp.slug})) diff --git a/src/info/admin.py b/src/info/admin.py index e8b9235a..b2287b77 100644 --- a/src/info/admin.py +++ b/src/info/admin.py @@ -1,6 +1,26 @@ from django.contrib import admin -from .models import * +from reversion.admin import VersionAdmin +from .models import ( + InfoItem, + InfoCategory +) -admin.site.register(InfoCategory) -admin.site.register(InfoItem) +@admin.register(InfoItem) +class InfoItemAdmin(VersionAdmin): + list_filter = ['category', 'category__team__camp',] + list_display = ['headline',] + + +class InfoItemInlineAdmin(admin.StackedInline): + model = InfoItem + list_filter = ['category', 'category__team__camp',] + list_display = ['headline',] + + +@admin.register(InfoCategory) +class InfoCategorydmin(admin.ModelAdmin): + list_filter = ['team__camp',] + list_display = ['headline',] + search_fields = ['headline', 'body'] + inlines = [InfoItemInlineAdmin] diff --git a/src/info/apps.py b/src/info/apps.py index 7fca4660..3891f47e 100644 --- a/src/info/apps.py +++ b/src/info/apps.py @@ -1,5 +1,3 @@ - - from django.apps import AppConfig diff --git a/src/info/migrations/0004_infocategory_team.py b/src/info/migrations/0004_infocategory_team.py new file mode 100644 index 00000000..d7d2acd2 --- /dev/null +++ b/src/info/migrations/0004_infocategory_team.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.4 on 2018-05-04 21:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0042_auto_20180413_1933'), + ('info', '0003_auto_20170218_1148'), + ] + + operations = [ + migrations.AddField( + model_name='infocategory', + name='team', + field=models.ForeignKey(blank=True, help_text='The team responsible for this info category.', null=True, on_delete=django.db.models.deletion.PROTECT, to='teams.Team'), + ), + ] diff --git a/src/info/migrations/0005_add_teams_to_categories.py b/src/info/migrations/0005_add_teams_to_categories.py new file mode 100644 index 00000000..404cb176 --- /dev/null +++ b/src/info/migrations/0005_add_teams_to_categories.py @@ -0,0 +1,78 @@ +# Generated by Django 2.0.4 on 2018-05-08 07:42 + +from django.db import migrations +from django.core.exceptions import ObjectDoesNotExist + + +def add_teams_to_categories(apps, schema_editor): + InfoCategory = apps.get_model("info", "InfoCategory") + Team = apps.get_model("teams", "Team") + Camp = apps.get_model("camps", "Camp") + + try: + # 2016 - Everything is orga team + camp2016 = Camp.objects.get(slug="bornhack-2016") + orga2016 = Team.objects.get(camp=camp2016, name="Orga") + InfoCategory.objects.filter(camp=camp2016).update(team=orga2016) + + # 2017 - Everything is orga team + camp2017 = Camp.objects.get(slug="bornhack-2017") + orga2017 = Team.objects.get(camp=camp2017, name="Orga") + InfoCategory.objects.filter(camp=camp2017).update(team=orga2017) + + # 2018 - Map categories to teams + camp2018 = Camp.objects.get(slug="bornhack-2018") + team2018 = Team.objects.filter(camp=camp2018) + infocategories2018 = InfoCategory.objects.filter(camp=camp2018) + + # Info team + infoteam = team2018.get(name="Info") + info_anchors = [ + "what", + "when", + "travel", + "where", + "sleep", + "bicycles", + "infodesk-and-cert", + "shower-and-toilets", + "venue-map", + "villages", + ] + infocategories2018.filter(anchor__in=info_anchors).update(team=infoteam) + + # Food team + food = team2018.get(name="Food") + infocategories2018.filter(anchor__in=["food-and-groceries"]).update(team=food) + + # NOC team + noc = team2018.get(name="NOC") + infocategories2018.filter(anchor__in=["network"]).update(team=noc) + + # Power team + power = team2018.get(name="Power") + infocategories2018.filter(anchor__in=["power"]).update(team=power) + + # Shuttle bus + shuttle_bus = team2018.get(name="Shuttle Bus") + infocategories2018.filter(anchor__in=["shuttle-bus"]).update(team=shuttle_bus) + + # Bar + bar = team2018.get(name="Bar") + infocategories2018.filter(anchor__in=["bar"]).update(team=bar) + + # Make info team catch all remaining + infocategories2018.filter(team__isnull=True).update(team=infoteam) + except ObjectDoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('info', '0004_infocategory_team'), + ] + + operations = [ + migrations.RunPython(add_teams_to_categories) + ] diff --git a/src/info/migrations/0006_auto_20180520_1113.py b/src/info/migrations/0006_auto_20180520_1113.py new file mode 100644 index 00000000..d7cef8ba --- /dev/null +++ b/src/info/migrations/0006_auto_20180520_1113.py @@ -0,0 +1,27 @@ +# Generated by Django 2.0.4 on 2018-05-20 16:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('info', '0005_add_teams_to_categories'), + ] + + operations = [ + migrations.AlterField( + model_name='infocategory', + name='team', + field=models.ForeignKey(blank=True, help_text='The team responsible for this info category.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='info_categories', to='teams.Team'), + ), + migrations.AlterUniqueTogether( + name='infocategory', + unique_together=set(), + ), + migrations.RemoveField( + model_name='infocategory', + name='camp', + ), + ] diff --git a/src/info/migrations/0007_auto_20180520_1511.py b/src/info/migrations/0007_auto_20180520_1511.py new file mode 100644 index 00000000..c2511a93 --- /dev/null +++ b/src/info/migrations/0007_auto_20180520_1511.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.4 on 2018-05-20 20:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('info', '0006_auto_20180520_1113'), + ] + + operations = [ + migrations.AlterField( + model_name='infocategory', + name='team', + field=models.ForeignKey(help_text='The team responsible for this info category.', on_delete=django.db.models.deletion.PROTECT, related_name='info_categories', to='teams.Team'), + ), + ] diff --git a/src/info/models.py b/src/info/models.py index e166b2d7..3888a53f 100644 --- a/src/info/models.py +++ b/src/info/models.py @@ -1,43 +1,55 @@ from django.db import models -from utils.models import CreatedUpdatedModel, CampRelatedModel +from utils.models import CampRelatedModel from django.core.exceptions import ValidationError +import reversion + class InfoCategory(CampRelatedModel): class Meta: ordering = ['weight', 'headline'] - unique_together = (('anchor', 'camp'), ('headline', 'camp')) verbose_name_plural = "Info Categories" - camp = models.ForeignKey( - 'camps.Camp', - related_name = 'infocategories', - on_delete = models.PROTECT - ) - headline = models.CharField( - max_length = 100, - help_text = "The headline of this info category" + max_length=100, + help_text="The headline of this info category" ) anchor = models.SlugField( - help_text = "The HTML anchor to use for this info category." + help_text="The HTML anchor to use for this info category." ) weight = models.PositiveIntegerField( - help_text = 'Determines sorting/ordering. Heavier categories sink to the bottom. Categories with the same weight are ordered alphabetically. Defaults to 100.', - default = 100, + help_text='Determines sorting/ordering. Heavier categories sink to the bottom. Categories with the same weight are ordered alphabetically. Defaults to 100.', + default=100, + ) + + team = models.ForeignKey( + 'teams.Team', + help_text='The team responsible for this info category.', + on_delete=models.PROTECT, + related_name='info_categories' ) def clean(self): - if InfoItem.objects.filter(category__camp=self.camp, anchor=self.anchor).exists(): + if InfoItem.objects.filter(category__team__camp=self.camp, anchor=self.anchor).exists(): # this anchor is already in use on an item, so it cannot be used (must be unique on the page) - raise ValidationError({'anchor': 'Anchor is already in use on an info item for this camp'}) + raise ValidationError( + {'anchor': 'Anchor is already in use on an info item for this camp'} + ) + + @property + def camp(self): + return self.team.camp + + camp_filter = 'team__camp' def __str__(self): return '%s (%s)' % (self.headline, self.camp) +# We want to have info items under version control +@reversion.register() class InfoItem(CampRelatedModel): class Meta: ordering = ['weight', 'headline'] @@ -45,38 +57,38 @@ class InfoItem(CampRelatedModel): category = models.ForeignKey( 'info.InfoCategory', - related_name = 'infoitems', - on_delete = models.PROTECT + related_name='infoitems', + on_delete=models.PROTECT ) headline = models.CharField( - max_length = 100, - help_text = "Headline of this info item." + max_length=100, + help_text="Headline of this info item." ) anchor = models.SlugField( - help_text = "The HTML anchor to use for this info item." + help_text="The HTML anchor to use for this info item." ) body = models.TextField( - help_text = 'Body of this info item. Markdown is supported.' + help_text='Body of this info item. Markdown is supported.' ) weight = models.PositiveIntegerField( - help_text = 'Determines sorting/ordering. Heavier items sink to the bottom. Items with the same weight are ordered alphabetically. Defaults to 100.', - default = 100, + help_text='Determines sorting/ordering. Heavier items sink to the bottom. Items with the same weight are ordered alphabetically. Defaults to 100.', + default=100, ) @property def camp(self): return self.category.camp + camp_filter = 'category__team__camp' + def clean(self): - if InfoCategory.objects.filter(camp=self.category.camp, anchor=self.anchor).exists(): + if hasattr(self, 'category') and InfoCategory.objects.filter(team__camp=self.category.team.camp, anchor=self.anchor).exists(): # this anchor is already in use on a category, so it cannot be used here (they must be unique on the entire page) raise ValidationError({'anchor': 'Anchor is already in use on an info category for this camp'}) def __str__(self): return '%s (%s)' % (self.headline, self.category) - - diff --git a/src/info/templates/info.html b/src/info/templates/info.html index 4441db43..cdbc7ecd 100644 --- a/src/info/templates/info.html +++ b/src/info/templates/info.html @@ -44,7 +44,7 @@ Info | {{ block.super }}
-

{{ category.headline }}

+

{{ category.headline }} {% if category.team %}Info by the {{ category.team.name }} team{% endif %}

{% for item in category.infoitems.all %}
@@ -54,10 +54,17 @@ Info | {{ block.super }} + {% if request.user in category.team.responsible_members.all %} + + Edit + + {% endif %} +
-

{{ item.body|commonmark }}

+

{{ item.body|trustedcommonmark }}

{% endfor %} diff --git a/src/info/views.py b/src/info/views.py index 08e14ea5..a9db1920 100644 --- a/src/info/views.py +++ b/src/info/views.py @@ -1,6 +1,4 @@ -from django.shortcuts import render -from django.views.generic import ListView, DetailView -from django.utils import timezone +from django.views.generic import ListView from .models import * from camps.mixins import CampViewMixin diff --git a/src/ircbot/admin.py b/src/ircbot/admin.py index 91278423..eff2cb89 100644 --- a/src/ircbot/admin.py +++ b/src/ircbot/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import * +from .models import OutgoingIrcMessage admin.site.register(OutgoingIrcMessage) diff --git a/src/ircbot/apps.py b/src/ircbot/apps.py index d057f8c7..54a5147b 100644 --- a/src/ircbot/apps.py +++ b/src/ircbot/apps.py @@ -1,5 +1,3 @@ - - from django.apps import AppConfig diff --git a/src/ircbot/irc3module.py b/src/ircbot/irc3module.py index 2de24b83..d6b2367a 100644 --- a/src/ircbot/irc3module.py +++ b/src/ircbot/irc3module.py @@ -1,7 +1,11 @@ -import irc3 +import irc3, re from ircbot.models import OutgoingIrcMessage +from teams.models import Team, TeamMember from django.conf import settings from django.utils import timezone +from events.models import Routing +from teams.utils import get_team_from_irc_channel + import logging logger = logging.getLogger("bornhack.%s" % __name__) @@ -13,7 +17,6 @@ class Plugin(object): requires = [ 'irc3.plugins.core', # makes the bot able to connect to an irc server and do basic irc stuff 'irc3.plugins.userlist', # maintains a convenient list of the channels the bot is in and their users - 'irc3.plugins.command', # what does this do? ] def __init__(self, bot): @@ -27,6 +30,12 @@ class Plugin(object): """triggered after the server sent the MOTD (require core plugin)""" logger.debug("inside server_ready(), kwargs: %s" % kwargs) + logger.info("Identifying with %s" % settings.IRCBOT_NICKSERV_MASK) + self.bot.privmsg(settings.IRCBOT_NICKSERV_MASK, "identify %s %s" % (settings.IRCBOT_NICK, settings.IRCBOT_NICKSERV_PASSWORD)) + + logger.info("Calling self.bot.do_stuff() in %s seconds.." % settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS) + self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.do_stuff) + def connection_lost(self, **kwargs): """triggered when connection is lost""" @@ -37,9 +46,6 @@ class Plugin(object): """triggered when connection is up""" logger.debug("inside connection_made(), kwargs: %s" % kwargs) - # wait 5 secs before starting the loop to check for outgoing messages - self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.get_outgoing_messages) - ############################################################################################### ### decorated irc3 event methods @@ -49,16 +55,38 @@ class Plugin(object): """triggered when there is a join part or quit on a channel the bot is in""" logger.debug("inside on_join_part_quit(), kwargs: %s" % kwargs) + # TODO: on part or quit check if the bot is the only remaining member of a channel, + # if so, check if the channel should be managed, and if so, part and join the channel + # to gain @ and register with ChanServ + + + @irc3.event(irc3.rfc.JOIN) + def on_join(self, mask, channel, **kwargs): + """Triggered when a channel is joined by someone, including the bot itself""" + if mask.nick == self.bot.nick: + # the bot just joined a channel + if channel in self.get_managed_team_channels() or channel == settings.IRCBOT_PUBLIC_CHANNEL or channel == settings.IRCBOT_VOLUNTEER_CHANNEL: + logger.debug("Just joined a channel I am supposed to be managing, asking ChanServ for info about %s" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "info %s" % channel) + return + @irc3.event(irc3.rfc.PRIVMSG) def on_privmsg(self, **kwargs): """triggered when a privmsg is sent to the bot or to a channel the bot is in""" + # we only handle NOTICEs for now + if kwargs['event'] != "NOTICE": + return + logger.debug("inside on_privmsg(), kwargs: %s" % kwargs) - # nickserv - if kwargs['mask'] == "NickServ!NickServ@services.baconsvin.org" and kwargs['event'] == "NOTICE" and kwargs['data'] == "This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify \x02.": - logger.info("Nickserv identify needed, fixing...") - self.bot.privmsg("NickServ@services.baconsvin.org", "identify %s %s" % (settings.IRCBOT_NICK, settings.IRCBOT_NICKSERV_PASSWORD)) + # check if this is a message from nickserv + if kwargs['mask'] == "NickServ!%s" % settings.IRCBOT_NICKSERV_MASK: + self.bot.handle_nickserv_privmsg(**kwargs) + + # check if this is a message from chanserv + if kwargs['mask'] == "ChanServ!%s" % settings.IRCBOT_CHANSERV_MASK: + self.bot.handle_chanserv_privmsg(**kwargs) @irc3.event(irc3.rfc.KICK) @@ -67,7 +95,23 @@ class Plugin(object): ############################################################################################### - ### custom irc3 methods + ### custom irc3 methods below here + + @irc3.extend + def do_stuff(self): + """ + Main periodic method called every N seconds. + """ + #logger.debug("inside do_stuff()") + + # call the methods we need to + self.bot.check_irc_channels() + self.bot.fix_missing_acls() + self.bot.get_outgoing_messages() + + # schedule a call of this function again in N seconds + self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.do_stuff) + @irc3.extend def get_outgoing_messages(self): @@ -75,7 +119,6 @@ class Plugin(object): This method gets unprocessed OutgoingIrcMessage objects and attempts to send them to the target channel. Messages are skipped if the bot is not in the channel. """ - #logger.debug("inside get_outgoing_messages()") for msg in OutgoingIrcMessage.objects.filter(processed=False).order_by('created'): logger.info("processing irc message to %s: %s" % (msg.target, msg.message)) # if this message expired mark it as expired and processed without doing anything @@ -100,7 +143,292 @@ class Plugin(object): else: logger.warning("skipping message to %s" % msg.target) - # call this function again in X seconds - self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.get_outgoing_messages) + + ############################################################################################### + ### irc channel methods + + @irc3.extend + def check_irc_channels(self): + """ + Compare the list of IRC channels the bot is currently in with the list of IRC channels the bot is supposed to be in. + Join or part channels as needed. + """ + desired_channel_list = self.bot.get_desired_channel_list() + #logger.debug("Inside check_irc_channels(), desired_channel_list is: %s and self.bot.channels is: %s" % (desired_channel_list, self.bot.channels.keys())) + + # loop over desired_channel_list, join as needed + for channel in desired_channel_list: + if channel not in self.bot.channels: + logger.debug("I should be in %s but I am not, attempting to join..." % channel) + self.bot.join(channel) + + # loop over self.bot.channels, part as needed + for channel in self.bot.channels: + if channel not in desired_channel_list: + logger.debug("I am in %s but I shouldn't be, parting..." % channel) + self.bot.part(channel, "I am no longer needed here") + @irc3.extend + def get_desired_channel_list(self): + """ + Return a list of strings of all the IRC channels the bot is supposed to be in + """ + desired_channel_list = self.get_managed_team_channels() + desired_channel_list += self.get_unmanaged_team_channels() + desired_channel_list.append(settings.IRCBOT_PUBLIC_CHANNEL) + desired_channel_list.append(settings.IRCBOT_VOLUNTEER_CHANNEL) + return desired_channel_list + + + @irc3.extend + def get_managed_team_channels(self): + """ + Return a list of team IRC channels which the bot is supposed to be managing. + """ + pubchans = Team.objects.filter( + public_irc_channel_name__isnull=False, + public_irc_channel_bot=True, + public_irc_channel_managed=True + ).values_list("public_irc_channel_name", flat=True) + + privchans = Team.objects.filter( + private_irc_channel_name__isnull=False, + private_irc_channel_bot=True, + private_irc_channel_managed=True + ).values_list("private_irc_channel_name", flat=True) + + return list(pubchans) + list(privchans) + + + @irc3.extend + def get_unmanaged_team_channels(self): + """ + Return a list of team IRC channels which the bot is supposed to be in, but not managing. + """ + pubchans = Team.objects.filter( + public_irc_channel_name__isnull=False, + public_irc_channel_bot=True, + public_irc_channel_managed=False + ).values_list("public_irc_channel_name", flat=True) + + privchans = Team.objects.filter( + private_irc_channel_name__isnull=False, + private_irc_channel_bot=True, + private_irc_channel_managed=False + ).values_list("private_irc_channel_name", flat=True) + + return list(pubchans) + list(privchans) + + + @irc3.extend + def setup_private_channel(self, channel): + """ + Configures a private IRC channel by setting modes and adding all members to ACL if it is a team channel + """ + logger.debug("Inside setup_private_channel() for %s" % channel) + + # basic private channel modes + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s mlock +inpst" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s SECURE on" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s RESTRICTED on" % channel) + + # add the bot to the ACL + self.bot.add_user_to_channel_acl( + username=settings.IRCBOT_NICK, + channel=channel, + invite=True + ) + + team = get_team_from_irc_channel(channel) + if team: + # this is a team channel, add team members to channel ACL + self.bot.add_team_members_to_channel_acl(team) + # make sure private_irc_channel_fix_needed is set to False and save + team.private_irc_channel_fix_needed=False + team.save() + + + @irc3.extend + def setup_public_channel(self, channel): + """ + Configures a public IRC channel by setting modes and giving all team members +oO if it is a team channel + """ + logger.debug("Inside setup_public_channel() for %s" % channel) + + # basic private channel modes + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s mlock +nt-lk" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s SECURE off" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s RESTRICTED off" % channel) + + team = get_team_from_irc_channel(channel) + if team: + # add members to ACL + self.bot.add_team_members_to_channel_acl(team) + # make sure public_irc_channel_fix_needed is set to False and save + team.public_irc_channel_fix_needed=False + team.save() + + + @irc3.extend + def add_team_members_to_channel_acl(self, team): + """ + Handles initial ACL for team channels. + Sets membership.irc_acl_fix_needed=True for each approved teammember with a NickServ username + """ + # add all members to the acl + for membership in team.memberships.all(): + if membership.approved and membership.user.profile.nickserv_username: + membership.irc_acl_fix_needed=True + membership.save() + + + @irc3.extend + def add_user_to_channel_acl(self, username, channel, invite): + """ + Add user to team IRC channel ACL + """ + # set autoop for this username + self.bot.privmsg( + settings.IRCBOT_CHANSERV_MASK, + "flags %(channel)s %(user)s +oO" % { + 'channel': channel, + 'user': username, + }, + ) + + if invite: + # also add autoinvite for this username + self.bot.mode(channel, '+I', '$a:%s' % username) + + + @irc3.extend + def fix_missing_acls(self): + """ + Called periodically by do_stuff() + Loops over TeamMember objects and adds ACL entries as needed + Loops over Team objects and fixes permissions and ACLS as needed + """ + # first find all TeamMember objects which needs a loving hand + missing_acls = TeamMember.objects.filter( + irc_acl_fix_needed=True + ).exclude( + user__profile__nickserv_username='' + ) + + # loop over them and fix what needs to be fixed + if missing_acls: + logger.debug("Found %s memberships which need IRC ACL fixing.." % missing_acls.count()) + for membership in missing_acls: + # add to team public channel? + if membership.team.public_irc_channel_name and membership.team.public_irc_channel_managed: + self.bot.add_user_to_channel_acl( + username=membership.user.profile.nickserv_username, + channel=membership.team.public_irc_channel_name, + invite=False + ) + + # add to team private channel? + if membership.team.private_irc_channel_name and membership.team.private_irc_channel_managed: + self.bot.add_user_to_channel_acl( + username=membership.user.profile.nickserv_username, + channel=membership.team.private_irc_channel_name, + invite=True + ) + + # add to volunteer channel + self.bot.add_user_to_channel_acl( + username=membership.user.profile.nickserv_username, + channel=settings.IRCBOT_VOLUNTEER_CHANNEL, + invite=True + ) + + # mark membership as irc_acl_fix_needed=False and save + membership.irc_acl_fix_needed=False + membership.save() + + # loop over teams where the private channel needs fixing + for team in Team.objects.filter(private_irc_channel_fix_needed=True): + logger.debug("Team %s private IRC channel %s needs ACL fixing" % (team, team.private_irc_channel_name)) + self.bot.setup_private_channel(team.private_irc_channel_name) + + # loop over teams where the public channel needs fixing + for team in Team.objects.filter(public_irc_channel_fix_needed=True): + logger.debug("Team %s public IRC channel %s needs ACL fixing" % (team, team.public_irc_channel_name)) + self.bot.setup_public_channel(team.public_irc_channel_name) + + + ############################################################################################### + ### services (ChanServ & NickServ) methods + + @irc3.extend + def handle_chanserv_privmsg(self, **kwargs): + """ + Handle messages from ChanServ on networks with Services. + """ + logger.debug("Got a message from ChanServ") + + ############################################### + # handle "Channel \x02#example\x02 is not registered." message + ############################################### + match = re.compile("Channel (#[a-zA-Z0-9-]+) is not registered.").match(kwargs['data'].replace("\x02", "")) + if match: + # the irc channel is not registered + channel = match.group(1) + # get a list of the channels we are supposed to be managing + if channel in self.bot.get_managed_team_channels() or channel == settings.IRCBOT_VOLUNTEER_CHANNEL: + # we want to register this channel! though we can only do so if we have a @ in the channel + if self.bot.nick in self.bot.channels[channel].modes['@']: + logger.debug("ChanServ says channel %s is not registered, bot is supposed to be managing this channel, registering it with chanserv" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "register %s" % channel) + else: + logger.debug("ChanServ says channel %s is not registered, bot is supposed to be managing this channel, but the bot cannot register without @ in the channel" % channel) + self.bot.privmsg(channel, "I need @ before I can register this channel with ChanServ") + return + + ############################################### + # handle "\x02#example\x02 is now registered to \x02tykbhdev\x02" message + ############################################### + match = re.compile("(#[a-zA-Z0-9-]+) is now registered to ([a-zA-Z0-9-]+)\\.").match(kwargs['data'].replace("\x02", "")) + if match: + # the irc channel is now registered + channel = match.group(1) + botnick = match.group(2) + logger.debug("Channel %s was registered with ChanServ, looking up Team..." % channel) + + team = get_team_from_irc_channel(channel) + if team: + if team.private_irc_channel_name == channel: + # set private channel modes, +I and ACL + self.bot.setup_private_channel(channel) + else: + # set public channel modes and +oO for all members + self.bot.setup_public_channel(channel) + return + logger.debug("Unable to find Team matching IRC channel %s" % channel) + + # check if this channel is the volunteer channel + if channel == settings.IRCBOT_VOLUNTEER_CHANNEL: + logger.debug("%s is the volunteer channel, setting up" % channel) + self.bot.setup_private_channel(channel) + # lets handle the volunteer channels initial ACL manually.. + return + + logger.debug("Unhandled ChanServ message: %s" % kwargs['data']) + + + @irc3.extend + def handle_nickserv_privmsg(self, **kwargs): + """ + Handles messages from NickServ on networks with Services. + """ + logger.debug("Got a message from NickServ") + + # handle "\x02botnick\x02 is not a registered nickname." message + if kwargs['data'] == '\x02%s\x02 is not a registered nickname.' % self.bot.nick: + # the bots nickname is not registered, register new account with nickserv + self.bot.privmsg(settings.IRCBOT_NICKSERV_MASK, "register %s %s" % (settings.IRCBOT_NICKSERV_PASSWORD, settings.IRCBOT_NICKSERV_EMAIL)) + return + + logger.debug("Unhandled NickServ message: %s" % kwargs['data']) + diff --git a/src/ircbot/ircworker.py b/src/ircbot/ircworker.py index 3e683057..5e5fb8d2 100644 --- a/src/ircbot/ircworker.py +++ b/src/ircbot/ircworker.py @@ -1,6 +1,7 @@ -from .models import OutgoingIrcMessage from django.conf import settings -import logging, irc3 +import logging +import irc3 +from events.models import Routing logging.basicConfig(level=logging.INFO) logger = logging.getLogger('bornhack.%s' % __name__) @@ -9,13 +10,20 @@ def do_work(): """ Run irc3 module code, wait for events on IRC and wait for messages in OutgoingIrcMessage """ + if hasattr(settings, 'IRCBOT_CHANNELS'): + logger.error("settings.IRCBOT_CHANNELS is deprecated. Please define settings.IRCBOT_PUBLIC_CHANNEL and use team channels for the rest.") + return False + config = { 'nick': settings.IRCBOT_NICK, - 'autojoins': list(set(settings.IRCBOT_CHANNELS.values())), + 'autojoins': [], 'host': settings.IRCBOT_SERVER_HOSTNAME, 'port': settings.IRCBOT_SERVER_PORT, 'ssl': settings.IRCBOT_SERVER_USETLS, 'timeout': 30, + 'flood_burst': 2, + 'flood_rate': 1, + 'flood_rate_delay': 2, 'includes': [ 'ircbot.irc3module', ], @@ -25,5 +33,5 @@ def do_work(): irc3.IrcBot(**config).run(forever=True) except Exception as E: logger.exception("Got exception inside do_work for %s" % self.workermodule) - raise + raise E diff --git a/src/ircbot/models.py b/src/ircbot/models.py index ae422fc8..05b56ead 100644 --- a/src/ircbot/models.py +++ b/src/ircbot/models.py @@ -1,5 +1,5 @@ from django.core.exceptions import ValidationError -from utils.models import UUIDModel, CreatedUpdatedModel +from utils.models import CreatedUpdatedModel from django.db import models from django.utils import timezone @@ -12,7 +12,7 @@ class OutgoingIrcMessage(CreatedUpdatedModel): expired = models.BooleanField(default=False) def __str__(self): - return "PRIVMSG %s %s (%s)" % (self.target, self.message, 'processed' if self.processed else 'unprocessed') + return "PRIVMSG %s %s (%s)" % (self.target, self.message, 'processed' if self.processed else 'unprocessed') def clean(self): if not self.pk: diff --git a/src/ircbot/utils.py b/src/ircbot/utils.py new file mode 100644 index 00000000..dfb27b69 --- /dev/null +++ b/src/ircbot/utils.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.utils import timezone +from datetime import timedelta +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def add_irc_message(target, message, timeout=10): + """ + Convenience function for adding OutgoingIrcMessage objects. + Defaults to a message timeout of 10 minutes + """ + from .models import OutgoingIrcMessage + OutgoingIrcMessage.objects.create( + target=target, + message=message, + timeout=timezone.now()+timedelta(minutes=timeout), + ) + + + diff --git a/src/news/apps.py b/src/news/apps.py index 0077041d..5a7b92d0 100644 --- a/src/news/apps.py +++ b/src/news/apps.py @@ -1,5 +1,3 @@ - - from django.apps import AppConfig diff --git a/src/news/models.py b/src/news/models.py index ee8a028e..31322fea 100644 --- a/src/news/models.py +++ b/src/news/models.py @@ -1,8 +1,7 @@ - - from django.db import models from django.utils import encoding from django.utils.text import slugify +from django.urls import reverse from utils.models import CreatedUpdatedModel @@ -44,3 +43,5 @@ class NewsItem(CreatedUpdatedModel): super(NewsItem, self).save(**kwargs) + def get_absolute_url(self): + return reverse('news:detail', kwargs={"slug": self.slug}) diff --git a/src/news/templates/news_detail.html b/src/news/templates/news_detail.html index 8cc0c953..676eb89b 100644 --- a/src/news/templates/news_detail.html +++ b/src/news/templates/news_detail.html @@ -14,5 +14,5 @@ {% endif %}

{{ news_item.title }} {{ news_item.published_at|date:"Y-m-d" }}

- {{ news_item.content|commonmark }} + {{ news_item.content|trustedcommonmark|urlize }} {% endblock %} diff --git a/src/news/templates/news_index.html b/src/news/templates/news_index.html index 6b2760bb..98ca8a02 100644 --- a/src/news/templates/news_index.html +++ b/src/news/templates/news_index.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% load commonmark %} +{% load static %} {% block title %} News | {{ block.super }} @@ -8,12 +9,18 @@ News | {{ block.super }} {% block content %} {% if request.resolver_match.kwargs.archived %} Showing archived news items. Show regular news items +{% else %} +
+ + Get BornHack news as a RSS feed + +
{% endif %} {% for item in news_items %}

{{ item.title }} {{ item.published_at|date:"Y-m-d" }}

- {{ item.content|commonmark }} + {{ item.content|trustedcommonmark|urlize }} {% if not forloop.last %}
{% endif %} diff --git a/src/news/urls.py b/src/news/urls.py index 766b4e3c..af6127ad 100644 --- a/src/news/urls.py +++ b/src/news/urls.py @@ -1,10 +1,11 @@ -from django.conf.urls import url +from django.urls import path from . import views - +app_name = 'news' urlpatterns = [ - url(r'^$', views.NewsIndex.as_view(), kwargs={'archived': False}, name='index'), - url(r'^archive/$', views.NewsIndex.as_view(), kwargs={'archived': True}, name='archive'), - url(r'(?P[-_\w+]+)/$', views.NewsDetail.as_view(), name='detail'), + path('', views.NewsIndex.as_view(), kwargs={'archived': False}, name='index'), + path('archive/', views.NewsIndex.as_view(), kwargs={'archived': True}, name='archive'), + path('feed/', views.NewsFeed(), name='feed'), + path('/', views.NewsDetail.as_view(), name='detail'), ] diff --git a/src/news/views.py b/src/news/views.py index 8056bc9d..79220bc4 100644 --- a/src/news/views.py +++ b/src/news/views.py @@ -1,6 +1,22 @@ from django.views.generic import ListView, DetailView from django.utils import timezone -from .models import * +from django.contrib.syndication.views import Feed + +from .models import NewsItem + + +def news_items_queryset(kwargs=None): + if not kwargs: + archived = False + else: + archived = kwargs['archived'] + + return NewsItem.objects.filter( + published_at__isnull=False, + published_at__lt=timezone.now(), + archived=archived + ) + class NewsIndex(ListView): model = NewsItem @@ -8,11 +24,7 @@ class NewsIndex(ListView): context_object_name = 'news_items' def get_queryset(self): - return NewsItem.objects.filter( - published_at__isnull=False, - published_at__lt=timezone.now(), - archived=self.kwargs['archived'] - ) + return news_items_queryset(self.kwargs) class NewsDetail(DetailView): @@ -20,3 +32,16 @@ class NewsDetail(DetailView): template_name = 'news_detail.html' context_object_name = 'news_item' + +class NewsFeed(Feed): + title = "BornHack News" + link = "/news" + + def items(self): + return news_items_queryset() + + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.content diff --git a/src/people/admin.py b/src/people/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/src/people/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/src/people/models.py b/src/people/models.py deleted file mode 100644 index 71a83623..00000000 --- a/src/people/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/src/people/templates/people.html b/src/people/templates/people.html index aaa2e70c..076b59bd 100644 --- a/src/people/templates/people.html +++ b/src/people/templates/people.html @@ -21,6 +21,7 @@ People | {{ block.super }} Team Name + Team Responsible Team Members @@ -31,19 +32,20 @@ People | {{ block.super }} {{ team.name }} Team - {% if team.anoncount == 0 and team.approvedmembers.count == 0 %} - No team member(s) - {% elif team.approvedmembers.count == team.anoncount %} - {{ team.anoncount }} anonymous member(s) - {% endif %} - - {% for member in team.approvedmembers.all %} - {% if member.user.profile.approved_public_credit_name %} - {{ member.user.profile.approved_public_credit_name }}{% if member in team.responsible.all %} (responsible){% endif %}
- {% endif %} + {% for resp in team.responsible_members.all %} + {{ resp.profile.get_public_credit_name }}
{% endfor %} - {% if team.anoncount and team.anoncount != team.approvedmembers.count %} - plus {{ team.anoncount }} anonymous member(s). + + + {% for member in team.regular_members.all %} + {% if member.profile.get_public_credit_name != "Unnamed" %} + {{ member.profile.get_public_credit_name }}
+ {% endif %} + {% empty %} + No team members + {% endfor %} + {% if team.unnamed_members %} + {% if team.unnamed_members.count < team.regular_members.count %}Plus {% endif %}{{ team.unnamed_members.count }} anonymous member(s). {% endif %} diff --git a/src/people/views.py b/src/people/views.py index e9ec181f..570e3e7e 100644 --- a/src/people/views.py +++ b/src/people/views.py @@ -1,4 +1,3 @@ -from django.shortcuts import render from django.views.generic import ListView from camps.models import Camp diff --git a/src/profiles/__init__.py b/src/profiles/__init__.py index e69de29b..562016c4 100644 --- a/src/profiles/__init__.py +++ b/src/profiles/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'profiles.apps.ProfilesConfig' + diff --git a/src/profiles/admin.py b/src/profiles/admin.py index a2c46950..795ccad4 100644 --- a/src/profiles/admin.py +++ b/src/profiles/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from .models import Profile + @admin.register(Profile) class OrderAdmin(admin.ModelAdmin): actions = ['approve_public_credit_names'] @@ -13,6 +14,10 @@ class OrderAdmin(admin.ModelAdmin): 'public_credit_name_approved', ] + list_filter = [ + 'public_credit_name_approved', + ] + def approve_public_credit_names(self, request, queryset): for profile in queryset.filter(public_credit_name_approved=False): profile.approve_public_credit_name() diff --git a/src/profiles/apps.py b/src/profiles/apps.py new file mode 100644 index 00000000..307b5f7c --- /dev/null +++ b/src/profiles/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from django.db.models.signals import pre_save, post_save +from .signal_handlers import create_profile, profile_pre_save +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +class ProfilesConfig(AppConfig): + name = 'profiles' + + def ready(self): + # remember to include a dispatch_uid to prevent signals being called multiple times in certain corner cases + from django.contrib.auth.models import User + post_save.connect(create_profile, sender=User, dispatch_uid='user_post_save_signal') + pre_save.connect(profile_pre_save, sender='profiles.Profile', dispatch_uid='profile_pre_save_signal') + diff --git a/src/profiles/migrations/0001_initial.py b/src/profiles/migrations/0001_initial.py index 95aa9c79..e4617e2c 100644 --- a/src/profiles/migrations/0001_initial.py +++ b/src/profiles/migrations/0001_initial.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('uuid', models.UUIDField(serialize=False, editable=False, primary_key=True, default=uuid.uuid4)), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, help_text='The django user this profile belongs to.', verbose_name='User')), + ('user', models.OneToOneField(on_delete=models.PROTECT, to=settings.AUTH_USER_MODEL, help_text='The django user this profile belongs to.', verbose_name='User')), ], options={ 'verbose_name_plural': 'Profiles', diff --git a/src/profiles/migrations/0008_auto_20180325_2022.py b/src/profiles/migrations/0008_auto_20180325_2022.py new file mode 100644 index 00000000..efe72731 --- /dev/null +++ b/src/profiles/migrations/0008_auto_20180325_2022.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-25 18:22 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0007_auto_20170711_2025'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='public_credit_name', + field=models.CharField(blank=True, help_text='The name you want to appear on in the credits section of the public website (on Team and People pages). Leave this empty if you want your name hidden on the public webpages.', max_length=100), + ), + ] diff --git a/src/profiles/migrations/0009_profile_nickserv_username.py b/src/profiles/migrations/0009_profile_nickserv_username.py new file mode 100644 index 00000000..0b5bca3c --- /dev/null +++ b/src/profiles/migrations/0009_profile_nickserv_username.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-03 00:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0008_auto_20180325_2022'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='nickserv_username', + field=models.CharField(blank=True, help_text='Your NickServ username is used to manage team IRC channel access lists.', max_length=50), + ), + ] diff --git a/src/profiles/migrations/0010_auto_20180411_2305.py b/src/profiles/migrations/0010_auto_20180411_2305.py new file mode 100644 index 00000000..b92f0d9a --- /dev/null +++ b/src/profiles/migrations/0010_auto_20180411_2305.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-11 21:05 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0009_profile_nickserv_username'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='nickserv_username', + field=models.CharField(blank=True, help_text='Your NickServ username is used to manage team IRC channel access lists. Make sure you register with NickServ _before_ you enter the username here!', max_length=50), + ), + ] diff --git a/src/profiles/models.py b/src/profiles/models.py index c7fcb2ec..986ea120 100644 --- a/src/profiles/models.py +++ b/src/profiles/models.py @@ -1,9 +1,5 @@ from django.contrib.auth.models import User from django.db import models -from django.db.models.signals import ( - post_save, - pre_save -) from django.conf import settings from django.utils import timezone from django.dispatch import receiver @@ -11,7 +7,6 @@ from django.utils.translation import ugettext_lazy as _ from datetime import timedelta -from ircbot.models import OutgoingIrcMessage from utils.models import UUIDModel, CreatedUpdatedModel @@ -24,6 +19,7 @@ class Profile(CreatedUpdatedModel, UUIDModel): User, verbose_name=_('User'), help_text=_('The django user this profile belongs to.'), + on_delete=models.PROTECT ) name = models.CharField( @@ -42,7 +38,7 @@ class Profile(CreatedUpdatedModel, UUIDModel): public_credit_name = models.CharField( blank=True, max_length=100, - help_text='The name you want to appear on in the credits section of the public website (the People pages). Leave empty if you want no public credit.' + help_text='The name you want to appear on in the credits section of the public website (on Team and People pages). Leave this empty if you want your name hidden on the public webpages.' ) public_credit_name_approved = models.BooleanField( @@ -50,6 +46,12 @@ class Profile(CreatedUpdatedModel, UUIDModel): help_text='Check this box to approve this users public_credit_name. This will be unchecked automatically when the user edits public_credit_name' ) + nickserv_username = models.CharField( + blank=True, + max_length=50, + help_text='Your NickServ username is used to manage team IRC channel access lists. Make sure you register with NickServ _before_ you enter the username here!', + ) + @property def email(self): return self.user.email @@ -58,37 +60,21 @@ class Profile(CreatedUpdatedModel, UUIDModel): return self.user.username def approve_public_credit_name(self): + """ + This method just sets profile.public_credit_name_approved=True and calls save() + It is used in an admin action + """ self.public_credit_name_approved = True self.save() @property - def approved_public_credit_name(self): + def get_public_credit_name(self): + """ + Convenience method to return profile.public_credit_name if it is approved, + and the string "Unnamed" otherwise + """ if self.public_credit_name_approved: return self.public_credit_name else: - return False + return "Unnamed" - -@receiver(post_save, sender=User) -def create_profile(sender, created, instance, **kwargs): - if created: - Profile.objects.create(user=instance) - - -@receiver(pre_save, sender=Profile) -def changed_public_credit_name(sender, instance, **kwargs): - try: - original = sender.objects.get(pk=instance.pk) - except sender.DoesNotExist: - # newly created object, just pass - pass - else: - if not original.public_credit_name == instance.public_credit_name: - OutgoingIrcMessage.objects.create( - target=settings.IRCBOT_CHANNELS['orga'] if 'orga' in settings.IRCBOT_CHANNELS else settings.IRCBOT_CHANNELS['default'], - message='User {username} changed public credit name. please review and act accordingly: https://bornhack.dk/admin/profiles/profile/{uuid}/change/'.format( - username=instance.name, - uuid=instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=60) - ) diff --git a/src/profiles/signal_handlers.py b/src/profiles/signal_handlers.py new file mode 100644 index 00000000..ea963897 --- /dev/null +++ b/src/profiles/signal_handlers.py @@ -0,0 +1,85 @@ +from django.db.models.signals import ( + post_save, + pre_save +) +from events.handler import handle_team_event +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def create_profile(sender, created, instance, **kwargs): + """ + Signal handler called after a User object is saved. + Creates a Profile object when the User object was just created. + """ + from .models import Profile + if created: + Profile.objects.create(user=instance) + + +def profile_pre_save(sender, instance, **kwargs): + """ + Signal handler called before a Profile object is saved. + """ + try: + original = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + original = None + + public_credit_name_changed(instance, original) + nickserv_username_changed(instance, original) + + +def public_credit_name_changed(instance, original): + """ + Checks if a users public_credit_name has been changed, and triggers a public_credit_name_changed event if so + """ + if original and original.public_credit_name == instance.public_credit_name: + # public_credit_name has not been changed + return + + if original and original.public_credit_name and not original.public_credit_name_approved: + # the original.public_credit_name was not approved, no need to notify again + return + + # put the message together + message='User {username} changed public credit name. please review and act accordingly: https://bornhack.dk/admin/profiles/profile/{uuid}/change/'.format( + username=instance.name, + uuid=instance.uuid + ) + + # trigger the event + handle_team_event( + eventtype='public_credit_name_changed', + irc_message=message, + ) + + +def nickserv_username_changed(instance, original): + """ + Check if profile.nickserv_username was changed, and check irc_acl_fix_needed if so + This will be picked up by the IRC bot and fixed as needed + """ + if instance.nickserv_username and original and instance.nickserv_username != original.nickserv_username: + logger.debug("profile.nickserv_username changed for user %s, setting membership.irc_acl_fix_needed=True" % instance.user.username) + + # find team memberships for this user + from teams.models import TeamMember + memberships = TeamMember.objects.filter( + user=instance.user, + approved=True, + ) + + # loop over memberships + for membership in memberships: + if not membership.team.public_irc_channel_name and not membership.team.private_irc_channel_name: + # no irc channels for this team + continue + if not membership.team.public_irc_channel_managed and not membership.team.private_irc_channel_managed: + # irc channel(s) are not managed for this team + continue + + # ok, mark this membership as in need of fixing + membership.irc_acl_fix_needed = True + membership.save() + diff --git a/src/profiles/templates/profile_base_buttons.html b/src/profiles/templates/profile_base_buttons.html index 47f2a697..75e184e9 100644 --- a/src/profiles/templates/profile_base_buttons.html +++ b/src/profiles/templates/profile_base_buttons.html @@ -7,7 +7,7 @@ Manage emails - {% if user.is_authenticated and user.orders.exists %} + {% if user.orders.exists %} Orders @@ -20,6 +20,9 @@ {% endif %} {% endif %} + + Secret Tokens + Logout diff --git a/src/profiles/templates/profile_detail.html b/src/profiles/templates/profile_detail.html index eca56d17..09c44898 100644 --- a/src/profiles/templates/profile_detail.html +++ b/src/profiles/templates/profile_detail.html @@ -14,9 +14,13 @@ {{ profile.description|default:"N/A" }} - Public Credit Name (visible to the public, leave empty if you want no credits) + Public Credit Name (visible to the public, leave empty if you want no credits on this website) {{ profile.public_credit_name|default:"N/A" }} {% if profile.public_credit_name %}({% if profile.public_credit_name_approved %}approved{% else %}pending approval{% endif %}){% endif %} + + NickServ username (visible to the public on IRC, used to handle team channel ACLs) + {{ profile.nickserv_username|default:"N/A" }} + - Edit Profile + Edit Profile {% endblock profile_content %} diff --git a/src/profiles/templates/profile_form.html b/src/profiles/templates/profile_form.html index f8ebd8a0..60fd167d 100644 --- a/src/profiles/templates/profile_form.html +++ b/src/profiles/templates/profile_form.html @@ -6,7 +6,7 @@
{% csrf_token %} {% bootstrap_form form %} - - Cancel + + Cancel
{% endblock profile_content %} diff --git a/src/profiles/tests.py b/src/profiles/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/src/profiles/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/src/profiles/urls.py b/src/profiles/urls.py index 54c8123c..96af5551 100644 --- a/src/profiles/urls.py +++ b/src/profiles/urls.py @@ -1,8 +1,10 @@ -from django.conf.urls import url +from django.urls import path from .views import ProfileDetail, ProfileUpdate + +app_name = 'profiles' urlpatterns = [ - url(r'^$', ProfileDetail.as_view(), name='detail'), - url(r'^edit$', ProfileUpdate.as_view(), name='update'), + path('', ProfileDetail.as_view(), name='detail'), + path('edit', ProfileUpdate.as_view(), name='update'), ] diff --git a/src/profiles/views.py b/src/profiles/views.py index 79ae29bb..4ac9a02a 100644 --- a/src/profiles/views.py +++ b/src/profiles/views.py @@ -1,6 +1,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import DetailView, UpdateView -from django.core.urlresolvers import reverse_lazy +from django.urls import reverse_lazy from django.contrib import messages from . import models @@ -16,7 +16,7 @@ class ProfileDetail(LoginRequiredMixin, DetailView): class ProfileUpdate(LoginRequiredMixin, UpdateView): model = models.Profile - fields = ['name', 'description', 'public_credit_name'] + fields = ['name', 'description', 'public_credit_name', 'nickserv_username'] success_url = reverse_lazy('profiles:detail') template_name = 'profile_form.html' @@ -28,6 +28,6 @@ class ProfileUpdate(LoginRequiredMixin, UpdateView): # user changed the name (to something non blank) form.instance.public_credit_name_approved = False form.instance.save() - messages.info(self.request, 'Your profile has been updated.') + messages.success(self.request, 'Your profile has been updated.') return super().form_valid(form, **kwargs) diff --git a/src/program/admin.py b/src/program/admin.py index eebfa209..430c1417 100644 --- a/src/program/admin.py +++ b/src/program/admin.py @@ -11,9 +11,12 @@ from .models import ( EventType, EventInstance, EventLocation, + EventTrack, SpeakerProposal, EventProposal, - Favorite + Favorite, + UrlType, + Url ) @@ -21,13 +24,18 @@ from .models import ( class SpeakerProposalAdmin(admin.ModelAdmin): def mark_speakerproposal_as_approved(self, request, queryset): for sp in queryset: - sp.mark_as_approved() + sp.mark_as_approved(request) mark_speakerproposal_as_approved.description = 'Approve and create Speaker object(s)' actions = ['mark_speakerproposal_as_approved'] list_filter = ('camp', 'proposal_status', 'user') +def get_speakers_string(event_proposal): + return ', '.join(event_proposal.speakers.all().values_list('email', flat=True)) +get_speakers_string.short_description = 'Speakers' + + @admin.register(EventProposal) class EventProposalAdmin(admin.ModelAdmin): def mark_eventproposal_as_approved(self, request, queryset): @@ -40,14 +48,18 @@ class EventProposalAdmin(admin.ModelAdmin): return False else: try: - ep.mark_as_approved() + ep.mark_as_approved(request) except ValidationError as e: messages.error(request, e) return False mark_eventproposal_as_approved.description = 'Approve and create Event object(s)' + def get_speakers(self): + return + actions = ['mark_eventproposal_as_approved'] - list_filter = ('camp', 'proposal_status', 'user') + list_filter = ('event_type', 'proposal_status', 'track', 'user',) + list_display = ['title', get_speakers_string, 'event_type', 'proposal_status',] @admin.register(EventLocation) @@ -56,9 +68,16 @@ class EventLocationAdmin(admin.ModelAdmin): list_display = ('name', 'camp') +@admin.register(EventTrack) +class EventTrackAdmin(admin.ModelAdmin): + list_filter = ('camp',) + list_display = ('name', 'camp') + @admin.register(EventInstance) class EventInstanceAdmin(admin.ModelAdmin): - pass + list_display = ('event', 'when', 'location') + list_filter = ('event__track__camp', 'event') + search_fields = ['event__title'] @admin.register(EventType) @@ -69,6 +88,7 @@ class EventTypeAdmin(admin.ModelAdmin): @admin.register(Speaker) class SpeakerAdmin(admin.ModelAdmin): list_filter = ('camp',) + readonly_fields = ['proposal'] @admin.register(Favorite) @@ -82,7 +102,7 @@ class SpeakerInline(admin.StackedInline): @admin.register(Event) class EventAdmin(admin.ModelAdmin): - list_filter = ('camp', 'speakers') + list_filter = ('track', 'speakers') list_display = [ 'title', 'event_type', @@ -91,3 +111,14 @@ class EventAdmin(admin.ModelAdmin): inlines = [ SpeakerInline ] + + readonly_fields = ['proposal'] + +@admin.register(UrlType) +class UrlTypeAdmin(admin.ModelAdmin): + pass + +@admin.register(Url) +class UrlAdmin(admin.ModelAdmin): + pass + diff --git a/src/program/apps.py b/src/program/apps.py index a7ec74a0..8a5ef090 100644 --- a/src/program/apps.py +++ b/src/program/apps.py @@ -14,12 +14,10 @@ class ProgramConfig(AppConfig): from .signal_handlers import ( check_speaker_event_camp_consistency, check_speaker_camp_change, - notify_proposal_submitted ) m2m_changed.connect( check_speaker_event_camp_consistency, sender=Speaker.events.through ) pre_save.connect(check_speaker_camp_change, sender=Speaker) - pre_save.connect(notify_proposal_submitted, sender=SpeakerProposal) - pre_save.connect(notify_proposal_submitted, sender=EventProposal) + diff --git a/src/program/consumers.py b/src/program/consumers.py index 4c659066..c4bdeaaa 100644 --- a/src/program/consumers.py +++ b/src/program/consumers.py @@ -1,17 +1,23 @@ -from channels.generic.websockets import JsonWebsocketConsumer +from channels.generic.websocket import JsonWebsocketConsumer from camps.models import Camp -from .models import Event, EventInstance, Favorite, EventLocation, EventType, Speaker +from .models import ( + Event, + EventInstance, + Favorite, + EventLocation, + EventType, + EventTrack, + Speaker +) class ScheduleConsumer(JsonWebsocketConsumer): - http_user = True + groups = ['schedule_users'] - def connection_groups(self, **kwargs): - return ['schedule_users'] - - def raw_receive(self, message, **kwargs): - content = self.decode_json(message['text']) + def receive(self, text_data, **kwargs): + user = self.scope['user'] + content = self.decode_json(text_data) action = content.get('action') data = {} @@ -29,17 +35,38 @@ class ScheduleConsumer(JsonWebsocketConsumer): camp.get_days('camp') )) - events_query_set = Event.objects.filter(camp=camp) + events_query_set = Event.objects.filter(track__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_instances_query_set = EventInstance.objects.filter( + event__track__camp=camp + ) + event_instances = list([ + x.serialize(user=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_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]) + event_types = list([ + x.serialize() + for x in event_types_query_set + ]) + + event_tracks_query_set = EventTrack.objects.filter( + camp=camp + ) + event_tracks = list([ + x.serialize() + for x in event_tracks_query_set + ]) speakers_query_set = Speaker.objects.filter(camp=camp) speakers = list([x.serialize() for x in speakers_query_set]) @@ -50,6 +77,7 @@ class ScheduleConsumer(JsonWebsocketConsumer): "event_instances": event_instances, "event_locations": event_locations, "event_types": event_types, + "event_tracks": event_tracks, "speakers": speakers, "days": days, } @@ -60,18 +88,27 @@ class ScheduleConsumer(JsonWebsocketConsumer): event_instance_id = content.get('event_instance_id') event_instance = EventInstance.objects.get(id=event_instance_id) Favorite.objects.create( - user=message.user, + user=user, event_instance=event_instance ) if action == 'unfavorite': - event_instance_id = content.get('event_instance_id') - event_instance = EventInstance.objects.get(id=event_instance_id) - favorite = Favorite.objects.get(event_instance=event_instance, user=message.user) - favorite.delete() + try: + event_instance_id = content.get('event_instance_id') + event_instance = EventInstance.objects.get( + id=event_instance_id + ) + favorite = Favorite.objects.get( + event_instance=event_instance, + user=user + ) + favorite.delete() + except Favorite.DoesNotExist: + # We don't want to do anything. + return if data: - self.send(data) + self.send_json(data) def disconnect(self, message, **kwargs): pass diff --git a/src/program/forms.py b/src/program/forms.py new file mode 100644 index 00000000..cfa34c39 --- /dev/null +++ b/src/program/forms.py @@ -0,0 +1,355 @@ +import logging + +from django import forms +from django.core.exceptions import ImproperlyConfigured + +from .models import SpeakerProposal, EventProposal, EventTrack, Url, UrlType + +logger = logging.getLogger("bornhack.%s" % __name__) + + +class SpeakerProposalForm(forms.ModelForm): + """ + The SpeakerProposalForm. Takes an EventType in __init__ and changes fields accordingly. + """ + class Meta: + model = SpeakerProposal + fields = ['name', 'email', 'biography', 'needs_oneday_ticket', 'submission_notes'] + + def __init__(self, camp, eventtype=None, *args, **kwargs): + # initialise the form + super().__init__(*args, **kwargs) + + # adapt form based on EventType? + if not eventtype: + return + + if eventtype.name == 'Debate': + # fix label and help_text for the name field + self.fields['name'].label = 'Guest Name' + self.fields['name'].help_text = 'The name of a debate guest. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Guest Biography' + self.fields['biography'].help_text = 'The biography of the guest.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Guest Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this guest. Only visible to yourself and the BornHack organisers.' + + # no free tickets for workshops + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Lightning Talk': + # fix label and help_text for the name field + self.fields['name'].label = 'Speaker Name' + self.fields['name'].help_text = 'The name of the speaker. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Speaker Biography' + self.fields['biography'].help_text = 'The biography of the speaker.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Speaker Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this speaker. Only visible to yourself and the BornHack organisers.' + + # no free tickets for lightning talks + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Music Act': + # fix label and help_text for the name field + self.fields['name'].label = 'Artist Name' + self.fields['name'].help_text = 'The name of the artist. Can be a real name or artist alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Artist Description' + self.fields['biography'].help_text = 'The description of the artist.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Artist Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this artist. Only visible to yourself and the BornHack organisers.' + + # no oneday tickets for music acts + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Recreational Event': + # fix label and help_text for the name field + self.fields['name'].label = 'Host Name' + self.fields['name'].help_text = 'The name of the event host. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Host Biography' + self.fields['biography'].help_text = 'The biography of the host.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Host Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this host. Only visible to yourself and the BornHack organisers.' + + # no oneday tickets for music acts + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Talk': + # fix label and help_text for the name field + self.fields['name'].label = 'Speaker Name' + self.fields['name'].help_text = 'The name of the speaker. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Speaker Biography' + self.fields['biography'].help_text = 'The biography of the speaker.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Speaker Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this speaker. Only visible to yourself and the BornHack organisers.' + + elif eventtype.name == 'Workshop': + # fix label and help_text for the name field + self.fields['name'].label = 'Host Name' + self.fields['name'].help_text = 'The name of the workshop host. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Host Biography' + self.fields['biography'].help_text = 'The biography of the host.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Host Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this host. Only visible to yourself and the BornHack organisers.' + + # no free tickets for workshops + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Slacking Off': + # fix label and help_text for the name field + self.fields['name'].label = 'Host Name' + self.fields['name'].help_text = 'Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Host Biography' + self.fields['biography'].help_text = 'The biography of the host.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Host Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this host. Only visible to yourself and the BornHack organisers.' + + # no free tickets for workshops + del(self.fields['needs_oneday_ticket']) + + elif eventtype.name == 'Meetup': + # fix label and help_text for the name field + self.fields['name'].label = 'Host Name' + self.fields['name'].help_text = 'The name of the meetup host. Can be a real name or an alias.' + + # fix label and help_text for the biograpy field + self.fields['biography'].label = 'Host Biography' + self.fields['biography'].help_text = 'The biography of the host.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Host Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this host. Only visible to yourself and the BornHack organisers.' + + # no free tickets for workshops + del(self.fields['needs_oneday_ticket']) + + else: + raise ImproperlyConfigured("Unsupported event type, don't know which form class to use") + + +class EventProposalForm(forms.ModelForm): + """ + The EventProposalForm. Takes an EventType in __init__ and changes fields accordingly. + """ + + slides_url = forms.URLField( + label="Slides URL", + help_text="Add a URL to your slides.", + required=False + ) + + class Meta: + model = EventProposal + fields = ['title', 'abstract', 'allow_video_recording', 'duration', 'slides_url', 'submission_notes', 'track'] + + def clean_duration(self): + duration = self.cleaned_data['duration'] + if not duration or duration < 60 or duration > 180: + raise forms.ValidationError("Please keep duration between 60 and 180 minutes.") + return duration + + def clean_track(self): + track = self.cleaned_data['track'] + # TODO: make sure the track is part of the current camp, needs camp as form kwarg to verify + return track + + def save(self, commit=True, user=None, event_type=None): + eventproposal = super().save(commit=False) + if user: + eventproposal.user = user + if event_type: + eventproposal.event_type = event_type + eventproposal.save() + + if not event_type and hasattr(eventproposal, 'event_type'): + event_type = eventproposal.event_type + + if self.cleaned_data.get('slides_url') and event_type.name in ['Talk', 'Lightning Talk']: + url = self.cleaned_data.get('slides_url') + if not eventproposal.urls.filter(url=url).exists(): + slides_url = Url() + slides_url.eventproposal = eventproposal + slides_url.url = url + slides_url.urltype = UrlType.objects.get(name="Slides") + slides_url.save() + + return eventproposal + + def __init__(self, camp, eventtype=None, *args, **kwargs): + # initialise form + super().__init__(*args, **kwargs) + + # disable the empty_label for the track select box + self.fields['track'].empty_label = None + self.fields['track'].queryset = EventTrack.objects.filter(camp=camp) + + # make sure video_recording checkbox defaults to checked + self.fields['allow_video_recording'].initial = True + + if not (eventtype.name == 'Talk' or eventtype.name == 'Lightning Talk'): + # Only talk or lightning talk should show the slides_url field + del(self.fields['slides_url']) + + if eventtype.name == 'Debate': + # fix label and help_text for the title field + self.fields['title'].label = 'Title of debate' + self.fields['title'].help_text = 'The title of this debate' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Description' + self.fields['abstract'].help_text = 'The description of this debate' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Debate Act Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this debate. Only visible to yourself and the BornHack organisers.' + + # better placeholder text for duration field + self.fields['duration'].widget.attrs['placeholder'] = 'Debate Duration (minutes)' + + elif eventtype.name == 'Music Act': + # fix label and help_text for the title field + self.fields['title'].label = 'Title of music act' + self.fields['title'].help_text = 'The title of this music act/concert/set.' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Description' + self.fields['abstract'].help_text = 'The description of this music act' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Music Act Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this music act. Only visible to yourself and the BornHack organisers.' + + # no video recording for music acts + del(self.fields['allow_video_recording']) + + # better placeholder text for duration field + self.fields['duration'].widget.attrs['placeholder'] = 'Duration (minutes)' + + elif eventtype.name == 'Recreational Event': + # fix label and help_text for the title field + self.fields['title'].label = 'Event Title' + self.fields['title'].help_text = 'The title of this recreational event' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Event Abstract' + self.fields['abstract'].help_text = 'The description/abstract of this recreational event.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Event Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this recreational event. Only visible to yourself and the BornHack organisers.' + + # no video recording for music acts + del(self.fields['allow_video_recording']) + + # better placeholder text for duration field + self.fields['duration'].label = 'Event Duration' + self.fields['duration'].widget.attrs['placeholder'] = 'Duration (minutes)' + + elif eventtype.name == 'Talk' or eventtype.name == 'Lightning Talk': + # fix label and help_text for the title field + self.fields['title'].label = 'Title of Talk' + self.fields['title'].help_text = 'The title of this talk/presentation.' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Abstract of Talk' + self.fields['abstract'].help_text = 'The description/abstract of this talk/presentation. Explain what the audience will experience.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Talk Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this talk. Only visible to yourself and the BornHack organisers.' + + if self.fields.get('slides_url') and eventtype.name == "Lightning Talk": + self.fields['slides_url'].help_text += " You will only get assigned a slot if you have provided slides (a title slide is enough if you don't use slides for the talk). You can add an URL later if need be." + + # no duration for talks + del(self.fields['duration']) + + elif eventtype.name == 'Workshop': + # fix label and help_text for the title field + self.fields['title'].label = 'Workshop Title' + self.fields['title'].help_text = 'The title of this workshop.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Workshop Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this workshop. Only visible to yourself and the BornHack organisers.' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Workshop Abstract' + self.fields['abstract'].help_text = 'The description/abstract of this workshop. Explain what the participants will learn.' + + # no video recording for workshops + del(self.fields['allow_video_recording']) + + # duration field + self.fields['duration'].label = 'Workshop Duration' + self.fields['duration'].help_text = 'How much time (in minutes) should we set aside for this workshop? Please keep it between 60 and 180 minutes (1-3 hours).' + + elif eventtype.name == 'Slacking Off': + # fix label and help_text for the title field + self.fields['title'].label = 'Event Title' + self.fields['title'].help_text = 'The title of this recreational event.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Event Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this recreational event. Only visible to yourself and the BornHack organisers.' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Event Abstract' + self.fields['abstract'].help_text = 'The description/abstract of this event. Explain what the participants will experience.' + + # no video recording for recreational events + del(self.fields['allow_video_recording']) + + # duration field + self.fields['duration'].label = 'Event Duration' + self.fields['duration'].help_text = 'How much time (in minutes) should we set aside for this event? Please keep it between 60 and 180 minutes (1-3 hours).' + + elif eventtype.name == 'Meetup': + # fix label and help_text for the title field + self.fields['title'].label = 'Meetup Title' + self.fields['title'].help_text = 'The title of this meetup.' + + # fix label and help_text for the submission_notes field + self.fields['submission_notes'].label = 'Meetup Notes' + self.fields['submission_notes'].help_text = 'Private notes regarding this meetup. Only visible to yourself and the BornHack organisers.' + + # fix label and help_text for the abstract field + self.fields['abstract'].label = 'Meetup Abstract' + self.fields['abstract'].help_text = 'The description/abstract of this meetup. Explain what the meetup is about and who should attend.' + + # no video recording for meetups + del(self.fields['allow_video_recording']) + + # duration field + self.fields['duration'].label = 'Meetup Duration' + self.fields['duration'].help_text = 'How much time (in minutes) should we set aside for this meetup? Please keep it between 60 and 180 minutes (1-3 hours).' + + else: + raise ImproperlyConfigured("Unsupported event type, don't know which form class to use") + diff --git a/src/program/migrations/0046_auto_20180318_0906.py b/src/program/migrations/0046_auto_20180318_0906.py new file mode 100644 index 00000000..a9b5b961 --- /dev/null +++ b/src/program/migrations/0046_auto_20180318_0906.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 08:06 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0045_event_proposal'), + ] + + operations = [ + migrations.AlterField( + model_name='favorite', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='favorites', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/program/migrations/0047_auto_20180415_1159.py b/src/program/migrations/0047_auto_20180415_1159.py new file mode 100644 index 00000000..e0b6ada7 --- /dev/null +++ b/src/program/migrations/0047_auto_20180415_1159.py @@ -0,0 +1,85 @@ +# Generated by Django 2.0.4 on 2018-04-15 16:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0046_auto_20180318_0906'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='camp', + field=models.ForeignKey(help_text='The camp this event belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='events', to='camps.Camp'), + ), + migrations.AlterField( + model_name='event', + name='event_type', + field=models.ForeignKey(help_text='The type of this event', on_delete=django.db.models.deletion.PROTECT, to='program.EventType'), + ), + migrations.AlterField( + model_name='event', + name='proposal', + field=models.OneToOneField(blank=True, help_text='The event proposal object this event was created from', null=True, on_delete=django.db.models.deletion.PROTECT, to='program.EventProposal'), + ), + migrations.AlterField( + model_name='eventinstance', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='program.Event'), + ), + migrations.AlterField( + model_name='eventinstance', + name='location', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='eventinstances', to='program.EventLocation'), + ), + migrations.AlterField( + model_name='eventlocation', + name='camp', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='eventlocations', to='camps.Camp'), + ), + migrations.AlterField( + model_name='eventproposal', + name='camp', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='eventproposals', to='camps.Camp'), + ), + migrations.AlterField( + model_name='eventproposal', + name='event_type', + field=models.ForeignKey(help_text='The type of event', on_delete=django.db.models.deletion.PROTECT, to='program.EventType'), + ), + migrations.AlterField( + model_name='eventproposal', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='favorite', + name='event_instance', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='program.EventInstance'), + ), + migrations.AlterField( + model_name='speaker', + name='camp', + field=models.ForeignKey(help_text='The camp this speaker belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='speakers', to='camps.Camp'), + ), + migrations.AlterField( + model_name='speaker', + name='proposal', + field=models.OneToOneField(blank=True, help_text='The speaker proposal object this speaker was created from', null=True, on_delete=django.db.models.deletion.PROTECT, to='program.SpeakerProposal'), + ), + migrations.AlterField( + model_name='speakerproposal', + name='camp', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='speakerproposals', to='camps.Camp'), + ), + migrations.AlterField( + model_name='speakerproposal', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/program/migrations/0048_auto_20180512_1625.py b/src/program/migrations/0048_auto_20180512_1625.py new file mode 100644 index 00000000..a081dc08 --- /dev/null +++ b/src/program/migrations/0048_auto_20180512_1625.py @@ -0,0 +1,134 @@ +# Generated by Django 2.0.4 on 2018-05-12 14:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0026_auto_20180506_1633'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('program', '0047_auto_20180415_1159'), + ] + + operations = [ + migrations.CreateModel( + name='EventTrack', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField()), + ('camp', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='eventtracks', to='camps.Camp')), + ('managers', models.ManyToManyField(related_name='managed_tracks', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RemoveField( + model_name='speaker', + name='picture_large', + ), + migrations.RemoveField( + model_name='speaker', + name='picture_small', + ), + migrations.RemoveField( + model_name='speakerproposal', + name='picture_large', + ), + migrations.RemoveField( + model_name='speakerproposal', + name='picture_small', + ), + migrations.AddField( + model_name='eventproposal', + name='duration', + field=models.IntegerField(blank=True, default=None, help_text='How much time (in minutes) should we set aside for this act? Please keep it between 60 and 180 minutes (1-3 hours).', null=True), + ), + migrations.AddField( + model_name='eventtype', + name='description', + field=models.TextField(blank=True, default='', help_text='The description of this type of event. Used in content submission flow.'), + ), + migrations.AddField( + model_name='eventtype', + name='icon', + field=models.CharField(default='wrench', help_text="Name of the fontawesome icon to use, without the 'fa-' part", max_length=25), + ), + migrations.AddField( + model_name='eventtype', + name='oneday_ticket_possible', + field=models.BooleanField(default=False, help_text='Check if hosting an event of this type qualifies someone for a free oneday ticket'), + ), + migrations.AddField( + model_name='speaker', + name='needs_oneday_ticket', + field=models.BooleanField(default=False, help_text='Check if BornHack needs to provide a free one-day ticket for this speaker'), + ), + migrations.AddField( + model_name='speakerproposal', + name='needs_oneday_ticket', + field=models.BooleanField(default=False, help_text='Check if BornHack needs to provide a free one-day ticket for this speaker'), + ), + migrations.AlterField( + model_name='eventlocation', + name='icon', + field=models.CharField(help_text="Name of the fontawesome icon to use without the 'fa-' part", max_length=100), + ), + migrations.AlterField( + model_name='eventproposal', + name='abstract', + field=models.TextField(blank=True, help_text='The abstract for this event. Describe what the audience can expect to see/hear.'), + ), + migrations.AlterField( + model_name='eventproposal', + name='proposal_status', + field=models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=50), + ), + migrations.AlterField( + model_name='eventproposal', + name='speakers', + field=models.ManyToManyField(blank=True, help_text='Pick the speaker(s) for this event. If you cannot see anything here you need to go back and create Speaker Proposal(s) first.', related_name='eventproposals', to='program.SpeakerProposal'), + ), + migrations.AlterField( + model_name='eventproposal', + name='title', + field=models.CharField(help_text='The title of this event. Keep it short and memorable.', max_length=255), + ), + migrations.AlterField( + model_name='speakerproposal', + name='biography', + field=models.TextField(help_text='Biography of the speaker/artist/host. Markdown is supported.'), + ), + migrations.AlterField( + model_name='speakerproposal', + name='name', + field=models.CharField(help_text='Name or alias of the speaker/artist/host', max_length=150), + ), + migrations.AlterField( + model_name='speakerproposal', + name='proposal_status', + field=models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=50), + ), + migrations.AlterField( + model_name='speakerproposal', + name='submission_notes', + field=models.TextField(blank=True, help_text='Private notes for this speaker/artist/host. Only visible to the submitting user and the BornHack organisers.'), + ), + migrations.AddField( + model_name='event', + name='track', + field=models.ForeignKey(blank=True, help_text='The track this event belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='program.EventTrack'), + ), + migrations.AddField( + model_name='eventproposal', + name='track', + field=models.ForeignKey(blank=True, help_text='The track this event belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='eventproposals', to='program.EventTrack'), + ), + migrations.AlterUniqueTogether( + name='eventtrack', + unique_together={('camp', 'slug'), ('camp', 'name')}, + ), + ] diff --git a/src/program/migrations/0049_add_event_tracks.py b/src/program/migrations/0049_add_event_tracks.py new file mode 100644 index 00000000..d19c68ca --- /dev/null +++ b/src/program/migrations/0049_add_event_tracks.py @@ -0,0 +1,30 @@ +# Generated by Django 2.0.4 on 2018-05-12 14:29 + +from django.db import migrations + +def add_event_tracks(apps, schema_editor): + Camp = apps.get_model('camps', 'Camp') + EventTrack = apps.get_model('program', 'EventTrack') + EventProposal = apps.get_model('program', 'EventProposal') + Event = apps.get_model('program', 'Event') + for camp in Camp.objects.all(): + # create the default track for this camp + track = EventTrack.objects.create( + name="BornHack", + slug="bornhack", + camp=camp + ) + Event.objects.filter(camp=camp).update(track=track) + EventProposal.objects.filter(camp=camp).update(track=track) + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0048_auto_20180512_1625'), + ] + + operations = [ + migrations.RunPython(add_event_tracks), + ] + diff --git a/src/program/migrations/0050_auto_20180512_1650.py b/src/program/migrations/0050_auto_20180512_1650.py new file mode 100644 index 00000000..492e8e09 --- /dev/null +++ b/src/program/migrations/0050_auto_20180512_1650.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.4 on 2018-05-12 14:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0049_add_event_tracks'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='event', + unique_together={('track', 'title'), ('track', 'slug')}, + ), + migrations.RemoveField( + model_name='eventproposal', + name='camp', + ), + migrations.RemoveField( + model_name='event', + name='camp', + ), + ] diff --git a/src/program/migrations/0051_auto_20180512_1801.py b/src/program/migrations/0051_auto_20180512_1801.py new file mode 100644 index 00000000..c07569ad --- /dev/null +++ b/src/program/migrations/0051_auto_20180512_1801.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.4 on 2018-05-12 16:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0050_auto_20180512_1650'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='track', + field=models.ForeignKey(help_text='The track this event belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='events', to='program.EventTrack'), + ), + migrations.AlterField( + model_name='eventproposal', + name='track', + field=models.ForeignKey(help_text='The track this event belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='eventproposals', to='program.EventTrack'), + ), + ] diff --git a/src/program/migrations/0052_auto_20180519_2324.py b/src/program/migrations/0052_auto_20180519_2324.py new file mode 100644 index 00000000..3a1dedcb --- /dev/null +++ b/src/program/migrations/0052_auto_20180519_2324.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-05-19 21:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0051_auto_20180512_1801'), + ] + + operations = [ + migrations.AlterField( + model_name='eventproposal', + name='allow_video_recording', + field=models.BooleanField(default=False, help_text='Check if we can video record the event. Leave unchecked to avoid video recording.'), + ), + ] diff --git a/src/program/migrations/0053_auto_20180519_2325.py b/src/program/migrations/0053_auto_20180519_2325.py new file mode 100644 index 00000000..7d70c248 --- /dev/null +++ b/src/program/migrations/0053_auto_20180519_2325.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-05-19 21:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0052_auto_20180519_2324'), + ] + + operations = [ + migrations.AlterField( + model_name='eventproposal', + name='allow_video_recording', + field=models.BooleanField(default=False, help_text='Check to allow video recording of the event. Leave unchecked to avoid video recording.'), + ), + ] diff --git a/src/program/migrations/0054_auto_20180520_1509.py b/src/program/migrations/0054_auto_20180520_1509.py new file mode 100644 index 00000000..e859b9ab --- /dev/null +++ b/src/program/migrations/0054_auto_20180520_1509.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.4 on 2018-05-20 13:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0053_auto_20180519_2325'), + ] + + operations = [ + migrations.RemoveField( + model_name='eventtype', + name='oneday_ticket_possible', + ), + migrations.AddField( + model_name='eventtype', + name='host_title', + field=models.CharField(default='Person', help_text='What to call someone hosting this type of event. Like "Artist" for Music or "Speaker" for talks.', max_length=30), + ), + ] diff --git a/src/program/migrations/0055_auto_20180521_2354.py b/src/program/migrations/0055_auto_20180521_2354.py new file mode 100644 index 00000000..67f2b933 --- /dev/null +++ b/src/program/migrations/0055_auto_20180521_2354.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.4 on 2018-05-21 21:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0054_auto_20180520_1509'), + ] + + operations = [ + migrations.CreateModel( + name='Url', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('url', models.URLField(help_text='The actual URL')), + ('event', models.ForeignKey(blank=True, help_text='The event proposal object this URL belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='urls', to='program.Event')), + ('eventproposal', models.ForeignKey(blank=True, help_text='The event proposal object this URL belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='urls', to='program.EventProposal')), + ('speaker', models.ForeignKey(blank=True, help_text='The speaker proposal object this URL belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='urls', to='program.Speaker')), + ('speakerproposal', models.ForeignKey(blank=True, help_text='The speaker proposal object this URL belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='urls', to='program.SpeakerProposal')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UrlType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='The name of this type', max_length=25)), + ('icon', models.CharField(help_text="Name of the fontawesome icon to use without the 'fa-' part", max_length=100)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='url', + name='urltype', + field=models.ForeignKey(help_text='The type of this URL', on_delete=django.db.models.deletion.PROTECT, to='program.UrlType'), + ), + ] diff --git a/src/program/migrations/0056_add_urltypes.py b/src/program/migrations/0056_add_urltypes.py new file mode 100644 index 00000000..1ffea07e --- /dev/null +++ b/src/program/migrations/0056_add_urltypes.py @@ -0,0 +1,58 @@ +# Generated by Django 2.0.4 on 2018-05-21 21:55 + +from django.db import migrations + +def add_urltypes(apps, schema_editor): + UrlType = apps.get_model('program', 'UrlType') + + UrlType.objects.create( + name='Other', + icon='link', + ) + + UrlType.objects.create( + name='Homepage', + icon='link', + ) + + UrlType.objects.create( + name='Slides', + icon='link', + ) + + UrlType.objects.create( + name='Twitter', + icon='link', + ) + + UrlType.objects.create( + name='Mastodon', + icon='link', + ) + + UrlType.objects.create( + name='Facebook', + icon='link', + ) + + UrlType.objects.create( + name='Project', + icon='link', + ) + + UrlType.objects.create( + name='Blog', + icon='link', + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0055_auto_20180521_2354'), + ] + + operations = [ + migrations.RunPython(add_urltypes), + ] + diff --git a/src/program/migrations/0057_auto_20180522_0659.py b/src/program/migrations/0057_auto_20180522_0659.py new file mode 100644 index 00000000..910a7f6f --- /dev/null +++ b/src/program/migrations/0057_auto_20180522_0659.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-05-22 04:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0056_add_urltypes'), + ] + + operations = [ + migrations.AlterField( + model_name='urltype', + name='icon', + field=models.CharField(default='link', help_text="Name of the fontawesome icon to use without the 'fa-' part", max_length=100), + ), + ] diff --git a/src/program/migrations/0058_auto_20180523_0844.py b/src/program/migrations/0058_auto_20180523_0844.py new file mode 100644 index 00000000..c6e39171 --- /dev/null +++ b/src/program/migrations/0058_auto_20180523_0844.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.4 on 2018-05-23 06:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0057_auto_20180522_0659'), + ] + + operations = [ + migrations.AlterModelOptions( + name='urltype', + options={'ordering': ['name']}, + ), + migrations.AlterField( + model_name='urltype', + name='name', + field=models.CharField(help_text='The name of this type', max_length=25, unique=True), + ), + ] diff --git a/src/program/migrations/0059_auto_20180523_2241.py b/src/program/migrations/0059_auto_20180523_2241.py new file mode 100644 index 00000000..a32ae010 --- /dev/null +++ b/src/program/migrations/0059_auto_20180523_2241.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.4 on 2018-05-23 20:41 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0058_auto_20180523_0844'), + ] + + operations = [ + migrations.RemoveField( + model_name='url', + name='id', + ), + migrations.AddField( + model_name='url', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ] diff --git a/src/program/migrations/0060_auto_20180603_1455.py b/src/program/migrations/0060_auto_20180603_1455.py new file mode 100644 index 00000000..717dc01b --- /dev/null +++ b/src/program/migrations/0060_auto_20180603_1455.py @@ -0,0 +1,40 @@ +# Generated by Django 2.0.4 on 2018-06-03 12:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0059_auto_20180523_2241'), + ] + + operations = [ + migrations.AlterField( + model_name='eventtrack', + name='camp', + field=models.ForeignKey(help_text='The Camp this Track belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='eventtracks', to='camps.Camp'), + ), + migrations.AlterField( + model_name='eventtrack', + name='managers', + field=models.ManyToManyField(blank=True, help_text='If this track is managed by someone other than the Content team pick the users here.', related_name='managed_tracks', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='eventtrack', + name='name', + field=models.CharField(help_text='The name of this Track', max_length=100), + ), + migrations.AlterField( + model_name='eventtrack', + name='slug', + field=models.SlugField(help_text='The url slug for this Track'), + ), + migrations.AlterField( + model_name='speakerproposal', + name='camp', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='speakerproposals', to='camps.Camp'), + ), + ] diff --git a/src/program/migrations/0061_auto_20180603_1525.py b/src/program/migrations/0061_auto_20180603_1525.py new file mode 100644 index 00000000..0c88b474 --- /dev/null +++ b/src/program/migrations/0061_auto_20180603_1525.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.4 on 2018-06-03 13:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0060_auto_20180603_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='proposal', + field=models.OneToOneField(blank=True, editable=False, help_text='The event proposal object this event was created from', null=True, on_delete=django.db.models.deletion.PROTECT, to='program.EventProposal'), + ), + migrations.AlterField( + model_name='speaker', + name='proposal', + field=models.OneToOneField(blank=True, editable=False, help_text='The speaker proposal object this speaker was created from', null=True, on_delete=django.db.models.deletion.PROTECT, to='program.SpeakerProposal'), + ), + ] diff --git a/src/program/migrations/0062_auto_20180717_1720.py b/src/program/migrations/0062_auto_20180717_1720.py new file mode 100644 index 00000000..1f369760 --- /dev/null +++ b/src/program/migrations/0062_auto_20180717_1720.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-07-17 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0061_auto_20180603_1525'), + ] + + operations = [ + migrations.AlterField( + model_name='urltype', + name='icon', + field=models.CharField(default='fas fa-link', help_text="Name of the fontawesome icon to use, including the 'fab fa-' or 'fas fa-' part.", max_length=100), + ), + ] diff --git a/src/program/migrations/0063_auto_20180809_1525.py b/src/program/migrations/0063_auto_20180809_1525.py new file mode 100644 index 00000000..d089fe94 --- /dev/null +++ b/src/program/migrations/0063_auto_20180809_1525.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-08-09 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0062_auto_20180717_1720'), + ] + + operations = [ + migrations.AlterField( + model_name='eventproposal', + name='allow_video_recording', + field=models.BooleanField(default=True, help_text='Uncheck this to avoid video recording.'), + ), + ] diff --git a/src/program/migrations/0064_auto_20180810_1748.py b/src/program/migrations/0064_auto_20180810_1748.py new file mode 100644 index 00000000..a9312697 --- /dev/null +++ b/src/program/migrations/0064_auto_20180810_1748.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-10 15:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0063_auto_20180809_1525'), + ] + + operations = [ + migrations.AlterField( + model_name='eventproposal', + name='allow_video_recording', + field=models.BooleanField(default=False, help_text='Uncheck to avoid video recording.'), + ), + ] diff --git a/src/program/migrations/0065_speakerproposal_email.py b/src/program/migrations/0065_speakerproposal_email.py new file mode 100644 index 00000000..31c9ae2e --- /dev/null +++ b/src/program/migrations/0065_speakerproposal_email.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-18 11:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0064_auto_20180810_1748'), + ] + + operations = [ + migrations.AddField( + model_name='speakerproposal', + name='email', + field=models.EmailField(blank=True, help_text='The email of the speaker (defaults to the logged in user if empty.', max_length=150, null=True), + ), + ] diff --git a/src/program/migrations/0066_speaker_email.py b/src/program/migrations/0066_speaker_email.py new file mode 100644 index 00000000..9cc36b5d --- /dev/null +++ b/src/program/migrations/0066_speaker_email.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-18 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0065_speakerproposal_email'), + ] + + operations = [ + migrations.AddField( + model_name='speaker', + name='email', + field=models.EmailField(blank=True, help_text='The email of the speaker.', max_length=150, null=True), + ), + ] diff --git a/src/program/migrations/0067_auto_20180818_1634.py b/src/program/migrations/0067_auto_20180818_1634.py new file mode 100644 index 00000000..a03bed99 --- /dev/null +++ b/src/program/migrations/0067_auto_20180818_1634.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1 on 2018-08-18 14:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0066_speaker_email'), + ] + + operations = [ + migrations.AlterModelOptions( + name='eventproposal', + options={'permissions': (('can_approve_proposals', 'Can approve proposals'),)}, + ), + ] diff --git a/src/program/migrations/0068_add_email_to_speaker_and_speaker_proposal.py b/src/program/migrations/0068_add_email_to_speaker_and_speaker_proposal.py new file mode 100644 index 00000000..3c9c67c1 --- /dev/null +++ b/src/program/migrations/0068_add_email_to_speaker_and_speaker_proposal.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1 on 2018-08-18 17:57 + +from django.db import migrations + + +def add_email(apps, schema_editor): + Speaker = apps.get_model('program', 'Speaker') + SpeakerProposal = apps.get_model('program', 'SpeakerProposal') + + for speaker in Speaker.objects.all(): + if speaker.proposal and not speaker.email: + speaker.email = speaker.proposal.user.email + speaker.save() + + for speaker_proposal in SpeakerProposal.objects.all(): + if not speaker_proposal.email: + speaker_proposal.email = speaker_proposal.user.email + speaker_proposal.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0067_auto_20180818_1634'), + ] + + operations = [ + migrations.RunPython(add_email) + ] diff --git a/src/program/migrations/0069_add_bogus_email_to_old_speakers.py b/src/program/migrations/0069_add_bogus_email_to_old_speakers.py new file mode 100644 index 00000000..94974a75 --- /dev/null +++ b/src/program/migrations/0069_add_bogus_email_to_old_speakers.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-08-19 15:14 + +from django.db import migrations + + +def add_bogus_email(apps, schema_editor): + Speaker = apps.get_model('program', 'Speaker') + + for speaker in Speaker.objects.all(): + if not speaker.email: + speaker.email = "N/A" + speaker.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0068_add_email_to_speaker_and_speaker_proposal'), + ] + + operations = [ + migrations.RunPython(add_bogus_email) + ] diff --git a/src/program/migrations/0070_auto_20180819_1729.py b/src/program/migrations/0070_auto_20180819_1729.py new file mode 100644 index 00000000..3b01d175 --- /dev/null +++ b/src/program/migrations/0070_auto_20180819_1729.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-08-19 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0069_add_bogus_email_to_old_speakers'), + ] + + operations = [ + migrations.AlterField( + model_name='speaker', + name='email', + field=models.EmailField(help_text='The email of the speaker.', max_length=150), + ), + migrations.AlterField( + model_name='speakerproposal', + name='email', + field=models.EmailField(help_text='The email of the speaker (defaults to the logged in user if empty.', max_length=150), + ), + ] diff --git a/src/program/migrations/0071_auto_20180827_1958.py b/src/program/migrations/0071_auto_20180827_1958.py new file mode 100644 index 00000000..00e7ce51 --- /dev/null +++ b/src/program/migrations/0071_auto_20180827_1958.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.4 on 2018-08-27 17:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0070_auto_20180819_1729'), + ] + + operations = [ + migrations.AlterModelOptions( + name='eventproposal', + options={}, + ), + ] diff --git a/src/program/mixins.py b/src/program/mixins.py index 79a837ab..2fa7407d 100644 --- a/src/program/mixins.py +++ b/src/program/mixins.py @@ -1,49 +1,41 @@ from django.views.generic.detail import SingleObjectMixin -from django.shortcuts import redirect +from django.shortcuts import redirect, get_object_or_404 from django.urls import reverse from . import models from django.contrib import messages -import sys, mimetypes from django.http import Http404, HttpResponse -class EnsureCFSOpenMixin(SingleObjectMixin): +class EnsureCFPOpenMixin(object): def dispatch(self, request, *args, **kwargs): - # do not permit editing if call for speakers is not open - if not self.camp.call_for_speakers_open: - messages.error(request, "The Call for Speakers is not open.") - return redirect(reverse('proposal_list', kwargs={'camp_slug': self.camp.slug})) + # do not permit this action if call for participation is not open + if not self.camp.call_for_participation_open: + messages.error(request, "The Call for Participation is not open.") + return redirect( + reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) + ) # alright, continue with the request return super().dispatch(request, *args, **kwargs) -class CreateProposalMixin(SingleObjectMixin): - def form_valid(self, form): - # set camp and user before saving - form.instance.camp = self.camp - form.instance.user = self.request.user - speaker = form.save() - return redirect(reverse('proposal_list', kwargs={'camp_slug': self.camp.slug})) - - class EnsureUnapprovedProposalMixin(SingleObjectMixin): def dispatch(self, request, *args, **kwargs): # do not permit editing if the proposal is already approved if self.get_object().proposal_status == models.UserSubmittedModel.PROPOSAL_APPROVED: messages.error(request, "This proposal has already been approved. Please contact the organisers if you need to modify something.") - return redirect(reverse('proposal_list', kwargs={'camp_slug': self.camp.slug})) + return redirect(reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) # alright, continue with the request return super().dispatch(request, *args, **kwargs) -class EnsureWritableCampMixin(SingleObjectMixin): +class EnsureWritableCampMixin(object): def dispatch(self, request, *args, **kwargs): # do not permit view if camp is in readonly mode if self.camp.read_only: messages.error(request, "No thanks") - return redirect(reverse('proposal_list', kwargs={'camp_slug': self.camp.slug})) + return redirect(reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) # alright, continue with the request return super().dispatch(request, *args, **kwargs) @@ -54,43 +46,49 @@ class EnsureUserOwnsProposalMixin(SingleObjectMixin): # make sure that this proposal belongs to the logged in user if self.get_object().user.username != request.user.username: messages.error(request, "No thanks") - return redirect(reverse('proposal_list', kwargs={'camp_slug': self.camp.slug})) + return redirect( + reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) + ) # alright, continue with the request return super().dispatch(request, *args, **kwargs) -class PictureViewMixin(SingleObjectMixin): +class UrlViewMixin(object): + """ + Mixin with code shared between all the Url views + """ def dispatch(self, request, *args, **kwargs): - # do we have the requested picture? - if kwargs['picture'] == 'thumbnail': - if self.get_object().picture_small: - self.picture = self.get_object().picture_small - else: - raise Http404() - elif kwargs['picture'] == 'large': - if self.get_object().picture_large: - self.picture = self.get_object().picture_large - else: - raise Http404() + """ + Check that we have a valid SpeakerProposal or EventProposal and that it belongs to the current user + """ + # get the proposal + if 'event_uuid' in self.kwargs: + self.eventproposal = get_object_or_404(models.EventProposal, uuid=self.kwargs['event_uuid'], user=request.user) + elif 'speaker_uuid' in self.kwargs: + self.speakerproposal = get_object_or_404(models.SpeakerProposal, uuid=self.kwargs['speaker_uuid'], user=request.user) else: - # only 'thumbnail' and 'large' pictures supported - raise Http404() - - # alright, continue with the request + # fuckery afoot + raise Http404 return super().dispatch(request, *args, **kwargs) - def get_picture_response(self, path): - if 'runserver' in sys.argv or 'runserver_plus' in sys.argv: - # this is a local devserver situation, guess mimetype from extension and return picture directly - response = HttpResponse(self.picture, content_type=mimetypes.types_map[".%s" % self.picture.name.split(".")[-1]]) + def get_context_data(self, **kwargs): + """ + Include the proposal in the template context + """ + context = super().get_context_data(**kwargs) + if hasattr(self, 'eventproposal') and self.eventproposal: + context['eventproposal'] = self.eventproposal else: - # make nginx serve the picture using X-Accel-Redirect - # (this works for nginx only, other webservers use x-sendfile) - # TODO: maybe make the header name configurable - response = HttpResponse() - response['X-Accel-Redirect'] = path - response['Content-Type'] = '' - return response + context['speakerproposal'] = self.speakerproposal + return context + def get_success_url(self): + """ + Return to the detail view of the proposal + """ + if hasattr(self, 'eventproposal'): + return self.eventproposal.get_absolute_url() + else: + return self.speakerproposal.get_absolute_url() diff --git a/src/program/models.py b/src/program/models.py index b34bed8a..348127c7 100644 --- a/src/program/models.py +++ b/src/program/models.py @@ -1,65 +1,160 @@ import uuid import os import icalendar -import CommonMark import logging - from datetime import timedelta -from django.contrib.postgres.fields import DateTimeRangeField +from django.contrib.postgres.fields import DateTimeRangeField, ArrayField from django.contrib import messages from django.db import models from django.core.exceptions import ObjectDoesNotExist, ValidationError -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.urls import reverse_lazy from django.core.files.storage import FileSystemStorage from django.urls import reverse from django.apps import apps from django.core.files.base import ContentFile +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from utils.models import CreatedUpdatedModel, CampRelatedModel + + logger = logging.getLogger("bornhack.%s" % __name__) -class CustomUrlStorage(FileSystemStorage): - def __init__(self, location=None): - super(CustomUrlStorage, self).__init__(location) +class UrlType(CreatedUpdatedModel): + """ + Each Url object has a type. + """ + name = models.CharField( + max_length=25, + help_text='The name of this type', + unique=True, + ) - def url(self, name): - url = super(CustomUrlStorage, self).url(name) - parts = url.split("/") - if parts[0] != "public": - # first bit should always be "public" - return False + icon = models.CharField( + max_length=100, + default='fas fa-link', + help_text="Name of the fontawesome icon to use, including the 'fab fa-' or 'fas fa-' part." + ) - if parts[1] == "speakerproposals": - # find speakerproposal - speakerproposal_model = apps.get_model('program', 'speakerproposal') - try: - speakerproposal = speakerproposal_model.objects.get(picture_small=name) - picture = "small" - except speakerproposal_model.DoesNotExist: - try: - speakerproposal = speakerproposal_model.objects.get(picture_large=name) - picture = "large" - except speakerproposal_model.DoesNotExist: - return False - url = reverse('speakerproposal_picture', kwargs={ - 'camp_slug': speakerproposal.camp.slug, - 'pk': speakerproposal.pk, - 'picture': picture, - }) + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + +class Url(CampRelatedModel): + """ + This model contains URLs related to + - SpeakerProposals + - EventProposals + - Speakers + - Events + Each URL has a UrlType and a GenericForeignKey to the model to which it belongs. + When a SpeakerProposal or EventProposal is approved the related URLs will be copied with FK to the new Speaker/Event objects. + """ + uuid = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + + url = models.URLField( + help_text='The actual URL' + ) + + urltype = models.ForeignKey( + 'program.UrlType', + help_text='The type of this URL', + on_delete=models.PROTECT, + ) + + speakerproposal = models.ForeignKey( + 'program.SpeakerProposal', + null=True, + blank=True, + help_text='The speaker proposal object this URL belongs to', + on_delete=models.PROTECT, + related_name='urls', + ) + + eventproposal = models.ForeignKey( + 'program.EventProposal', + null=True, + blank=True, + help_text='The event proposal object this URL belongs to', + on_delete=models.PROTECT, + related_name='urls', + ) + + speaker = models.ForeignKey( + 'program.Speaker', + null=True, + blank=True, + help_text='The speaker proposal object this URL belongs to', + on_delete=models.PROTECT, + related_name='urls', + ) + + event = models.ForeignKey( + 'program.Event', + null=True, + blank=True, + help_text='The event proposal object this URL belongs to', + on_delete=models.PROTECT, + related_name='urls', + ) + + def __str__(self): + return self.url + + def clean(self): + ''' Make sure we have exactly one FK ''' + fks = 0 + if self.speakerproposal: + fks += 1 + if self.eventproposal: + fks += 1 + if self.speaker: + fks += 1 + if self.event: + fks += 1 + if fks > 1: + raise(ValidationError("Url objects must have maximum one FK, this has %s" % fks)) + + @property + def owner(self): + """ + Return the object this Url belongs to + """ + if self.speakerproposal: + return self.speakerproposal + elif self.eventproposal: + return self.eventproposal + elif self.speaker: + return self.speaker + elif self.event: + return self.event else: - return False + return None - return url + @property + def camp(self): + return self.owner.camp + + camp_filter = [ + 'speakerproposal__camp', + 'eventproposal__track__camp', + 'speaker__camp', + 'event__track__camp', + ] -storage = CustomUrlStorage() +############################################################################### class UserSubmittedModel(CampRelatedModel): @@ -79,173 +174,180 @@ class UserSubmittedModel(CampRelatedModel): user = models.ForeignKey( 'auth.User', + on_delete=models.PROTECT ) - PROPOSAL_DRAFT = 'draft' PROPOSAL_PENDING = 'pending' PROPOSAL_APPROVED = 'approved' PROPOSAL_REJECTED = 'rejected' - PROPOSAL_MODIFIED_AFTER_APPROVAL = 'modified after approval' PROPOSAL_STATUSES = [ - PROPOSAL_DRAFT, PROPOSAL_PENDING, PROPOSAL_APPROVED, PROPOSAL_REJECTED, - PROPOSAL_MODIFIED_AFTER_APPROVAL ] PROPOSAL_STATUS_CHOICES = [ - (PROPOSAL_DRAFT, 'Draft'), (PROPOSAL_PENDING, 'Pending approval'), (PROPOSAL_APPROVED, 'Approved'), (PROPOSAL_REJECTED, 'Rejected'), - (PROPOSAL_MODIFIED_AFTER_APPROVAL, 'Modified after approval'), ] proposal_status = models.CharField( max_length=50, choices=PROPOSAL_STATUS_CHOICES, - default=PROPOSAL_DRAFT, + default=PROPOSAL_PENDING, ) def __str__(self): return '%s (submitted by: %s, status: %s)' % (self.headline, self.user, self.proposal_status) def save(self, **kwargs): - if not self.camp.call_for_speakers_open: - message = 'Call for speakers is not open' + if not self.camp.call_for_participation_open: + message = 'Call for participation is not open' if hasattr(self, 'request'): messages.error(self.request, message) raise ValidationError(message) super().save(**kwargs) def delete(self, **kwargs): - if not self.camp.call_for_speakers_open: - message = 'Call for speakers is not open' + if not self.camp.call_for_participation_open: + message = 'Call for participation is not open' if hasattr(self, 'request'): messages.error(self.request, message) raise ValidationError(message) super().delete(**kwargs) -def get_speakerproposal_picture_upload_path(instance, filename): - """ We want speakerproposal pictures saved as MEDIA_ROOT/public/speakerproposals/camp-slug/proposal-uuid/filename """ - return 'public/speakerproposals/%(campslug)s/%(proposaluuid)s/%(filename)s' % { - 'campslug': instance.camp.slug, - 'proposaluuid': instance.uuid, - 'filename': filename - } - - -def get_speakersubmission_picture_upload_path(instance, filename): - """ We want speakerproposal pictures saved as MEDIA_ROOT/public/speakerproposals/camp-slug/proposal-uuid/filename """ - return 'public/speakerproposals/%(campslug)s/%(proposaluuid)s/%(filename)s' % { - 'campslug': instance.camp.slug, - 'proposaluuidd': instance.uuid, - 'filename': filename - } - - class SpeakerProposal(UserSubmittedModel): """ A speaker proposal """ camp = models.ForeignKey( 'camps.Camp', - related_name='speakerproposals' + related_name='speakerproposals', + on_delete=models.PROTECT, + editable=False, ) name = models.CharField( max_length=150, - help_text='Name or alias of the speaker', + help_text='Name or alias of the speaker/artist/host', + ) + + email = models.EmailField( + max_length=150, + help_text="The email of the speaker (defaults to the logged in user if empty.", ) biography = models.TextField( - help_text='Markdown is supported.' - ) - - picture_large = models.ImageField( - null=True, - blank=True, - upload_to=get_speakerproposal_picture_upload_path, - help_text='A picture of the speaker', - storage=storage, - max_length=255 - ) - - picture_small = models.ImageField( - null=True, - blank=True, - upload_to=get_speakerproposal_picture_upload_path, - help_text='A thumbnail of the speaker picture', - storage=storage, - max_length=255 + help_text='Biography of the speaker/artist/host. Markdown is supported.' ) submission_notes = models.TextField( - help_text='Private notes for this speaker. Only visible to the submitting user and the BornHack organisers.', + help_text='Private notes for this speaker/artist/host. Only visible to the submitting user and the BornHack organisers.', blank=True ) + needs_oneday_ticket = models.BooleanField( + default=False, + help_text='Check if BornHack needs to provide a free one-day ticket for this speaker', + ) + @property def headline(self): return self.name def get_absolute_url(self): - return reverse_lazy('speakerproposal_detail', kwargs={'camp_slug': self.camp.slug, 'pk': self.uuid}) + return reverse_lazy('program:speakerproposal_detail', kwargs={'camp_slug': self.camp.slug, 'pk': self.uuid}) - def mark_as_approved(self): - speakermodel = apps.get_model('program', 'speaker') + def mark_as_approved(self, request): + """ Marks a SpeakerProposal as approved, including creating/updating the related Speaker object """ speakerproposalmodel = apps.get_model('program', 'speakerproposal') - speaker = speakermodel() + # create a Speaker if we don't have one + if not hasattr(self, 'speaker'): + speakermodel = apps.get_model('program', 'speaker') + speaker = speakermodel() + speaker.proposal = self + else: + speaker = self.speaker + + # set Speaker data speaker.camp = self.camp + if self.email: + email = self.email + else: + email = request.user.email + speaker.email = email speaker.name = self.name speaker.biography = self.biography - if self.picture_small and self.picture_large: - temp = ContentFile(self.picture_small.read()) - temp.name = os.path.basename(self.picture_small.name) - speaker.picture_small = temp - temp = ContentFile(self.picture_large.read()) - temp.name = os.path.basename(self.picture_large.name) - speaker.picture_large = temp - speaker.proposal = self + speaker.needs_oneday_ticket = self.needs_oneday_ticket speaker.save() + # mark as approved and save self.proposal_status = speakerproposalmodel.PROPOSAL_APPROVED self.save() + # copy all the URLs to the speaker object + speaker.urls.clear() + for url in self.urls.all(): + Url.objects.create( + url=url.url, + urltype=url.urltype, + speaker=speaker + ) + + # a message to the admin + messages.success(request, "Speaker object %s has been created/updated" % speaker) + + def mark_as_rejected(self, request): + speakerproposalmodel = apps.get_model('program', 'speakerproposal') + self.proposal_status = speakerproposalmodel.PROPOSAL_REJECTED + self.save() + messages.success(request, "SpeakerProposal %s has been rejected" % self.name) + class EventProposal(UserSubmittedModel): """ An event proposal """ - - camp = models.ForeignKey( - 'camps.Camp', - related_name='eventproposals' + track = models.ForeignKey( + 'program.EventTrack', + related_name='eventproposals', + help_text='The track this event belongs to', + on_delete=models.PROTECT ) title = models.CharField( max_length=255, - help_text='The title of this event', + help_text='The title of this event. Keep it short and memorable.', ) abstract = models.TextField( - help_text='The abstract for this event' + help_text='The abstract for this event. Describe what the audience can expect to see/hear.', + blank=True, ) event_type = models.ForeignKey( 'program.EventType', help_text='The type of event', + on_delete=models.PROTECT ) speakers = models.ManyToManyField( 'program.SpeakerProposal', blank=True, help_text='Pick the speaker(s) for this event. If you cannot see anything here you need to go back and create Speaker Proposal(s) first.', + related_name='eventproposals', ) allow_video_recording = models.BooleanField( default=False, - help_text='If we can video record the event or not' + help_text='Uncheck to avoid video recording.' + ) + + duration = models.IntegerField( + default=None, + null=True, + blank=True, + help_text='How much time (in minutes) should we set aside for this act? Please keep it between 60 and 180 minutes (1-3 hours).' ) submission_notes = models.TextField( @@ -253,21 +355,41 @@ class EventProposal(UserSubmittedModel): blank=True ) + @property + def camp(self): + return self.track.camp + + camp_filter = 'track__camp' + @property def headline(self): return self.title def get_absolute_url(self): return reverse_lazy( - 'eventproposal_detail', + 'program:eventproposal_detail', kwargs={'camp_slug': self.camp.slug, 'pk': self.uuid} ) - def mark_as_approved(self): + def get_available_speakerproposals(self): + """ + Return all SpeakerProposals submitted by the user who submitted this EventProposal, + which are not already added to this EventProposal + """ + return SpeakerProposal.objects.filter( + camp=self.track.camp, + user=self.user + ).exclude(uuid__in=self.speakers.all().values_list('uuid')) + + def mark_as_approved(self, request): eventmodel = apps.get_model('program', 'event') eventproposalmodel = apps.get_model('program', 'eventproposal') - event = eventmodel() - event.camp = self.camp + # use existing event if we have one + if not hasattr(self, 'event'): + event = eventmodel() + else: + event = self.event + event.track = self.track event.title = self.title event.abstract = self.abstract event.event_type = self.event_type @@ -279,15 +401,74 @@ class EventProposal(UserSubmittedModel): try: event.speakers.add(sp.speaker) except ObjectDoesNotExist: + # clean up + event.urls.clear() event.delete() raise ValidationError('Not all speakers are approved or created yet.') self.proposal_status = eventproposalmodel.PROPOSAL_APPROVED self.save() + # clear any old urls from the event object and copy all the URLs from the proposal + event.urls.clear() + for url in self.urls.all(): + Url.objects.create( + url=url.url, + urltype=url.urltype, + event=event + ) + + messages.success(request, "Event object %s has been created/updated" % event) + + def mark_as_rejected(self, request): + eventproposalmodel = apps.get_model('program', 'eventproposal') + self.proposal_status = eventproposalmodel.PROPOSAL_REJECTED + self.save() + messages.success(request, "EventProposal %s has been rejected" % self.title) + + ############################################################################### +class EventTrack(CampRelatedModel): + """ All events belong to a track. Administration of a track can be delegated to one or more users. """ + + name = models.CharField( + max_length=100, + help_text='The name of this Track', + ) + + slug = models.SlugField( + help_text='The url slug for this Track' + ) + + camp = models.ForeignKey( + 'camps.Camp', + related_name='eventtracks', + on_delete=models.PROTECT, + help_text='The Camp this Track belongs to', + ) + + managers = models.ManyToManyField( + 'auth.User', + related_name='managed_tracks', + blank=True, + help_text='If this track is managed by someone other than the Content team pick the users here.' + ) + + def __str__(self): + return self.name + + class Meta: + unique_together = (('camp', 'slug'), ('camp', 'name')) + + def serialize(self): + return { + "name": self.name, + "slug": self.slug, + } + + class EventLocation(CampRelatedModel): """ The places where stuff happens """ @@ -299,12 +480,13 @@ class EventLocation(CampRelatedModel): icon = models.CharField( max_length=100, - help_text="hex for the unicode character in the fontawesome icon set to use, like 'f000' for 'fa-glass'" + help_text="Name of the fontawesome icon to use without the 'fa-' part" ) camp = models.ForeignKey( 'camps.Camp', - related_name='eventlocations' + related_name='eventlocations', + on_delete=models.PROTECT ) def __str__(self): @@ -331,6 +513,12 @@ class EventType(CreatedUpdatedModel): slug = models.SlugField() + description = models.TextField( + default='', + help_text='The description of this type of event. Used in content submission flow.', + blank=True, + ) + color = models.CharField( max_length=50, help_text='The background color of this event type', @@ -341,6 +529,12 @@ class EventType(CreatedUpdatedModel): help_text='Check if this event type should use white text color', ) + icon = models.CharField( + max_length=25, + help_text="Name of the fontawesome icon to use, without the 'fa-' part", + default='wrench', + ) + notifications = models.BooleanField( default=False, help_text='Check to send notifications for this event type', @@ -356,6 +550,12 @@ class EventType(CreatedUpdatedModel): help_text='Include events of this type in the event list?', ) + host_title = models.CharField( + max_length=30, + help_text='What to call someone hosting this type of event. Like "Artist" for Music or "Speaker" for talks.', + default='Person', + ) + def __str__(self): return self.name @@ -383,6 +583,7 @@ class Event(CampRelatedModel): event_type = models.ForeignKey( 'program.EventType', help_text='The type of this event', + on_delete=models.PROTECT ) slug = models.SlugField( @@ -391,10 +592,11 @@ class Event(CampRelatedModel): help_text='The slug for this event, created automatically', ) - camp = models.ForeignKey( - 'camps.Camp', + track = models.ForeignKey( + 'program.EventTrack', related_name='events', - help_text='The camp this event belongs to', + help_text='The track this event belongs to', + on_delete=models.PROTECT ) video_url = models.URLField( @@ -414,11 +616,13 @@ class Event(CampRelatedModel): null=True, blank=True, help_text='The event proposal object this event was created from', + on_delete=models.PROTECT, + editable=False, ) class Meta: ordering = ['title'] - unique_together = (('camp', 'slug'), ('camp', 'title')) + unique_together = (('track', 'slug'), ('track', 'title')) def __str__(self): return '%s (%s)' % (self.title, self.camp.title) @@ -428,6 +632,12 @@ class Event(CampRelatedModel): self.slug = slugify(self.title) super(Event, self).save(**kwargs) + @property + def camp(self): + return self.track.camp + + camp_filter = 'track__camp' + @property def speakers_list(self): if self.speakers.exists(): @@ -435,7 +645,7 @@ class Event(CampRelatedModel): return False def get_absolute_url(self): - return reverse_lazy('event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug}) + return reverse_lazy('program:event_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug}) def serialize(self): data = { @@ -467,7 +677,8 @@ class EventInstance(CampRelatedModel): event = models.ForeignKey( 'program.event', - related_name='instances' + related_name='instances', + on_delete=models.PROTECT ) when = DateTimeRangeField() @@ -478,7 +689,8 @@ class EventInstance(CampRelatedModel): location = models.ForeignKey( 'program.EventLocation', - related_name='eventinstances' + related_name='eventinstances', + on_delete=models.PROTECT ) class Meta: @@ -495,6 +707,8 @@ class EventInstance(CampRelatedModel): def camp(self): return self.event.camp + camp_filter = 'event__track__camp' + @property def schedule_date(self): """ @@ -508,9 +722,7 @@ class EventInstance(CampRelatedModel): @property def timeslots(self): - """ - Find the number of timeslots this eventinstance takes up - """ + """ Find the number of timeslots this eventinstance takes up """ seconds = (self.when.upper-self.when.lower).seconds minutes = seconds / 60 return minutes / settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES @@ -529,13 +741,14 @@ class EventInstance(CampRelatedModel): 'title': self.event.title, 'slug': self.event.slug + '-' + str(self.id), 'event_slug': self.event.slug, - 'from': self.when.lower.astimezone().isoformat(), - 'to': self.when.upper.astimezone().isoformat(), + 'from': self.when.lower.isoformat(), + 'to': self.when.upper.isoformat(), 'url': str(self.event.get_absolute_url()), 'id': self.id, '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, + 'event_track': self.event.track.slug, 'location': self.location.slug, 'location_icon': self.location.icon, 'timeslots': self.timeslots, @@ -558,15 +771,6 @@ class EventInstance(CampRelatedModel): return data -def get_speaker_picture_upload_path(instance, filename): - """ We want speaker pictures are saved as MEDIA_ROOT/public/speakers/camp-slug/speaker-slug/filename """ - return 'public/speakers/%(campslug)s/%(speakerslug)s/%(filename)s' % { - 'campslug': instance.camp.slug, - 'speakerslug': instance.slug, - 'filename': filename - } - - class Speaker(CampRelatedModel): """ A Person (co)anchoring one or more events on a camp. """ @@ -575,24 +779,15 @@ class Speaker(CampRelatedModel): help_text='Name or alias of the speaker', ) + email = models.EmailField( + max_length=150, + help_text="The email of the speaker.", + ) + biography = models.TextField( help_text='Markdown is supported.' ) - picture_small = models.ImageField( - null=True, - blank=True, - upload_to=get_speaker_picture_upload_path, - help_text='A thumbnail of the speaker picture' - ) - - picture_large = models.ImageField( - null=True, - blank=True, - upload_to=get_speaker_picture_upload_path, - help_text='A picture of the speaker' - ) - slug = models.SlugField( blank=True, max_length=255, @@ -604,6 +799,7 @@ class Speaker(CampRelatedModel): null=True, related_name='speakers', help_text='The camp this speaker belongs to', + on_delete=models.PROTECT ) events = models.ManyToManyField( @@ -618,6 +814,13 @@ class Speaker(CampRelatedModel): null=True, blank=True, help_text='The speaker proposal object this speaker was created from', + on_delete=models.PROTECT, + editable=False, + ) + + needs_oneday_ticket = models.BooleanField( + default=False, + help_text='Check if BornHack needs to provide a free one-day ticket for this speaker', ) class Meta: @@ -633,17 +836,7 @@ class Speaker(CampRelatedModel): super(Speaker, self).save(**kwargs) 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') - + return reverse_lazy('program:speaker_detail', kwargs={'camp_slug': self.camp.slug, 'slug': self.slug}) def serialize(self): data = { @@ -651,18 +844,50 @@ class Speaker(CampRelatedModel): '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') - event_instance = models.ForeignKey('program.EventInstance') + user = models.ForeignKey( + 'auth.User', + related_name='favorites', + on_delete=models.PROTECT + ) + event_instance = models.ForeignKey( + 'program.EventInstance', + on_delete=models.PROTECT + ) class Meta: unique_together = ['user', 'event_instance'] +# classes and functions below here was used by picture handling for speakers before it was removed in May 2018 by tyk + +class CustomUrlStorage(FileSystemStorage): + """ + Must exist because it is mentioned in old migrations. + Can be removed when we clean up old migrations at some point + """ + pass + +def get_speaker_picture_upload_path(): + """ + Must exist because it is mentioned in old migrations. + Can be removed when we clean up old migrations at some point + """ + pass + +def get_speakerproposal_picture_upload_path(): + """ + Must exist because it is mentioned in old migrations. + Can be removed when we clean up old migrations at some point + """ + pass + +def get_speakersubmission_picture_upload_path(): + """ + Must exist because it is mentioned in old migrations. + Can be removed when we clean up old migrations at some point + """ + pass + diff --git a/src/program/schema.py b/src/program/schema.py new file mode 100644 index 00000000..54d93e39 --- /dev/null +++ b/src/program/schema.py @@ -0,0 +1,95 @@ +from graphene import relay + +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from .models import ( + Event, + EventType, + EventLocation, + EventTrack, + EventInstance, + Speaker, +) + + +class EventTypeNode(DjangoObjectType): + class Meta: + model = EventType + interfaces = (relay.Node, ) + filter_fields = { + 'name': ['iexact'], + } + + +class EventLocationNode(DjangoObjectType): + class Meta: + model = EventLocation + interfaces = (relay.Node, ) + filter_fields = { + 'name': ['iexact'], + } + + +class EventTrackNode(DjangoObjectType): + class Meta: + model = EventTrack + interfaces = (relay.Node, ) + filter_fields = { + 'name': ['iexact'], + } + + +class EventInstanceNode(DjangoObjectType): + + class Meta: + model = EventInstance + interfaces = (relay.Node, ) + filter_fields = { + 'event__title': ['iexact'], + } + + def resolve_when(self, info): + # We need to resolve this ourselves, graphene-django isn't smart enough + return [self.when.lower, self.when.upper] + + +class SpeakerNode(DjangoObjectType): + class Meta: + model = Speaker + interfaces = (relay.Node, ) + only_fields = ('id', 'name', 'biography', 'slug', 'camp', 'events') + filter_fields = { + 'name': ['iexact'], + } + + +class EventNode(DjangoObjectType): + class Meta: + model = Event + interfaces = (relay.Node, ) + filter_fields = { + 'title': ['iexact'], + 'track__camp__slug': ['iexact'], + } + + +class ProgramQuery(object): + event_type = relay.Node.Field(EventTypeNode) + all_event_types = DjangoFilterConnectionField(EventTypeNode) + + event_location = relay.Node.Field(EventLocationNode) + all_event_locations = DjangoFilterConnectionField(EventLocationNode) + + event_track = relay.Node.Field(EventTrackNode) + all_event_tracks = DjangoFilterConnectionField(EventTrackNode) + + event_instance = relay.Node.Field(EventInstanceNode) + all_event_instances = DjangoFilterConnectionField(EventInstanceNode) + + event = relay.Node.Field(EventNode) + all_events = DjangoFilterConnectionField(EventNode) + + speaker = relay.Node.Field(SpeakerNode) + all_speakers = DjangoFilterConnectionField(SpeakerNode) + diff --git a/src/program/signal_handlers.py b/src/program/signal_handlers.py index 08fbb4d5..55434053 100644 --- a/src/program/signal_handlers.py +++ b/src/program/signal_handlers.py @@ -8,7 +8,6 @@ from django.conf import settings from .email import add_new_speakerproposal_email, add_new_eventproposal_email from .models import EventProposal, SpeakerProposal -from ircbot.models import OutgoingIrcMessage logger = logging.getLogger("bornhack.%s" % __name__) @@ -37,42 +36,3 @@ def check_speaker_camp_change(sender, instance, **kwargs): if event.camp != instance.camp: raise ValidationError({'camp': 'You cannot change the camp a speaker belongs to if the speaker is associated with one or more events.'}) - -# pre_save signal that notifies if a proposal changes status from draft to -# pending i.e. is submitted. -def notify_proposal_submitted(sender, instance, **kwargs): - try: - original = sender.objects.get(pk=instance.pk) - except sender.DoesNotExist: - return False - - target = settings.IRCBOT_CHANNELS['orga'] if 'orga' in settings.IRCBOT_CHANNELS else settings.IRCBOT_CHANNELS['default'] - - if original.proposal_status == 'draft' and instance.proposal_status == 'pending': - if isinstance(instance, EventProposal): - if not add_new_eventproposal_email(instance): - logger.error( - 'Error adding event proposal email to outgoing queue for {}'.format(instance) - ) - OutgoingIrcMessage.objects.create( - target=target, - message="New event proposal: {} - https://bornhack.dk/admin/program/eventproposal/{}/change/".format( - instance.title, - instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=10) - ) - - if isinstance(instance, SpeakerProposal): - if not add_new_speakerproposal_email(instance): - logger.error( - 'Error adding speaker proposal email to outgoing queue for {}'.format(instance) - ) - OutgoingIrcMessage.objects.create( - target=target, - message="New speaker proposal: {} - https://bornhack.dk/admin/program/speakerproposal/{}/change/".format( - instance.name, - instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=10) - ) diff --git a/src/program/static/js/elm_based_schedule.js b/src/program/static/js/elm_based_schedule.js index 5e27d676..1ce403e8 100644 --- a/src/program/static/js/elm_based_schedule.js +++ b/src/program/static/js/elm_based_schedule.js @@ -5759,11 +5759,17 @@ var _elm_lang$core$Platform$Router = {ctor: 'Router'}; var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode = _elm_lang$core$Json_Decode$succeed; var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$resolve = _elm_lang$core$Json_Decode$andThen(_elm_lang$core$Basics$identity); -var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$custom = _elm_lang$core$Json_Decode$map2( - F2( - function (x, y) { - return y(x); - })); +var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$custom = F2( + function (decoder, wrapped) { + return A3( + _elm_lang$core$Json_Decode$map2, + F2( + function (x, y) { + return x(y); + }), + wrapped, + decoder); + }); var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$hardcoded = function (_p0) { return _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$custom( _elm_lang$core$Json_Decode$succeed(_p0)); @@ -5795,7 +5801,15 @@ var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$optionalDecoder = F3( return _elm_lang$core$Json_Decode$fail(_p2._0); } } else { - return _elm_lang$core$Json_Decode$succeed(fallback); + var _p3 = A2( + _elm_lang$core$Json_Decode$decodeValue, + _elm_lang$core$Json_Decode$keyValuePairs(_elm_lang$core$Json_Decode$value), + input); + if (_p3.ctor === 'Ok') { + return _elm_lang$core$Json_Decode$succeed(fallback); + } else { + return _elm_lang$core$Json_Decode$fail(_p3._0); + } } }; return A2(_elm_lang$core$Json_Decode$andThen, handleResult, _elm_lang$core$Json_Decode$value); @@ -5837,6 +5851,418 @@ var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required = F3( decoder); }); +//import Native.Scheduler // + +var _elm_lang$core$Native_Time = function() { + +var now = _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) +{ + callback(_elm_lang$core$Native_Scheduler.succeed(Date.now())); +}); + +function setInterval_(interval, task) +{ + return _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) + { + var id = setInterval(function() { + _elm_lang$core$Native_Scheduler.rawSpawn(task); + }, interval); + + return function() { clearInterval(id); }; + }); +} + +return { + now: now, + setInterval_: F2(setInterval_) +}; + +}(); +var _elm_lang$core$Task$onError = _elm_lang$core$Native_Scheduler.onError; +var _elm_lang$core$Task$andThen = _elm_lang$core$Native_Scheduler.andThen; +var _elm_lang$core$Task$spawnCmd = F2( + function (router, _p0) { + var _p1 = _p0; + return _elm_lang$core$Native_Scheduler.spawn( + A2( + _elm_lang$core$Task$andThen, + _elm_lang$core$Platform$sendToApp(router), + _p1._0)); + }); +var _elm_lang$core$Task$fail = _elm_lang$core$Native_Scheduler.fail; +var _elm_lang$core$Task$mapError = F2( + function (convert, task) { + return A2( + _elm_lang$core$Task$onError, + function (_p2) { + return _elm_lang$core$Task$fail( + convert(_p2)); + }, + task); + }); +var _elm_lang$core$Task$succeed = _elm_lang$core$Native_Scheduler.succeed; +var _elm_lang$core$Task$map = F2( + function (func, taskA) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return _elm_lang$core$Task$succeed( + func(a)); + }, + taskA); + }); +var _elm_lang$core$Task$map2 = F3( + function (func, taskA, taskB) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return _elm_lang$core$Task$succeed( + A2(func, a, b)); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map3 = F4( + function (func, taskA, taskB, taskC) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return _elm_lang$core$Task$succeed( + A3(func, a, b, c)); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map4 = F5( + function (func, taskA, taskB, taskC, taskD) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return A2( + _elm_lang$core$Task$andThen, + function (d) { + return _elm_lang$core$Task$succeed( + A4(func, a, b, c, d)); + }, + taskD); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map5 = F6( + function (func, taskA, taskB, taskC, taskD, taskE) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return A2( + _elm_lang$core$Task$andThen, + function (d) { + return A2( + _elm_lang$core$Task$andThen, + function (e) { + return _elm_lang$core$Task$succeed( + A5(func, a, b, c, d, e)); + }, + taskE); + }, + taskD); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$sequence = function (tasks) { + var _p3 = tasks; + if (_p3.ctor === '[]') { + return _elm_lang$core$Task$succeed( + {ctor: '[]'}); + } else { + return A3( + _elm_lang$core$Task$map2, + F2( + function (x, y) { + return {ctor: '::', _0: x, _1: y}; + }), + _p3._0, + _elm_lang$core$Task$sequence(_p3._1)); + } +}; +var _elm_lang$core$Task$onEffects = F3( + function (router, commands, state) { + return A2( + _elm_lang$core$Task$map, + function (_p4) { + return {ctor: '_Tuple0'}; + }, + _elm_lang$core$Task$sequence( + A2( + _elm_lang$core$List$map, + _elm_lang$core$Task$spawnCmd(router), + commands))); + }); +var _elm_lang$core$Task$init = _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}); +var _elm_lang$core$Task$onSelfMsg = F3( + function (_p7, _p6, _p5) { + return _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}); + }); +var _elm_lang$core$Task$command = _elm_lang$core$Native_Platform.leaf('Task'); +var _elm_lang$core$Task$Perform = function (a) { + return {ctor: 'Perform', _0: a}; +}; +var _elm_lang$core$Task$perform = F2( + function (toMessage, task) { + return _elm_lang$core$Task$command( + _elm_lang$core$Task$Perform( + A2(_elm_lang$core$Task$map, toMessage, task))); + }); +var _elm_lang$core$Task$attempt = F2( + function (resultToMessage, task) { + return _elm_lang$core$Task$command( + _elm_lang$core$Task$Perform( + A2( + _elm_lang$core$Task$onError, + function (_p8) { + return _elm_lang$core$Task$succeed( + resultToMessage( + _elm_lang$core$Result$Err(_p8))); + }, + A2( + _elm_lang$core$Task$andThen, + function (_p9) { + return _elm_lang$core$Task$succeed( + resultToMessage( + _elm_lang$core$Result$Ok(_p9))); + }, + task)))); + }); +var _elm_lang$core$Task$cmdMap = F2( + function (tagger, _p10) { + var _p11 = _p10; + return _elm_lang$core$Task$Perform( + A2(_elm_lang$core$Task$map, tagger, _p11._0)); + }); +_elm_lang$core$Native_Platform.effectManagers['Task'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Task$init, onEffects: _elm_lang$core$Task$onEffects, onSelfMsg: _elm_lang$core$Task$onSelfMsg, tag: 'cmd', cmdMap: _elm_lang$core$Task$cmdMap}; + +var _elm_lang$core$Time$setInterval = _elm_lang$core$Native_Time.setInterval_; +var _elm_lang$core$Time$spawnHelp = F3( + function (router, intervals, processes) { + var _p0 = intervals; + if (_p0.ctor === '[]') { + return _elm_lang$core$Task$succeed(processes); + } else { + var _p1 = _p0._0; + var spawnRest = function (id) { + return A3( + _elm_lang$core$Time$spawnHelp, + router, + _p0._1, + A3(_elm_lang$core$Dict$insert, _p1, id, processes)); + }; + var spawnTimer = _elm_lang$core$Native_Scheduler.spawn( + A2( + _elm_lang$core$Time$setInterval, + _p1, + A2(_elm_lang$core$Platform$sendToSelf, router, _p1))); + return A2(_elm_lang$core$Task$andThen, spawnRest, spawnTimer); + } + }); +var _elm_lang$core$Time$addMySub = F2( + function (_p2, state) { + var _p3 = _p2; + var _p6 = _p3._1; + var _p5 = _p3._0; + var _p4 = A2(_elm_lang$core$Dict$get, _p5, state); + if (_p4.ctor === 'Nothing') { + return A3( + _elm_lang$core$Dict$insert, + _p5, + { + ctor: '::', + _0: _p6, + _1: {ctor: '[]'} + }, + state); + } else { + return A3( + _elm_lang$core$Dict$insert, + _p5, + {ctor: '::', _0: _p6, _1: _p4._0}, + state); + } + }); +var _elm_lang$core$Time$inMilliseconds = function (t) { + return t; +}; +var _elm_lang$core$Time$millisecond = 1; +var _elm_lang$core$Time$second = 1000 * _elm_lang$core$Time$millisecond; +var _elm_lang$core$Time$minute = 60 * _elm_lang$core$Time$second; +var _elm_lang$core$Time$hour = 60 * _elm_lang$core$Time$minute; +var _elm_lang$core$Time$inHours = function (t) { + return t / _elm_lang$core$Time$hour; +}; +var _elm_lang$core$Time$inMinutes = function (t) { + return t / _elm_lang$core$Time$minute; +}; +var _elm_lang$core$Time$inSeconds = function (t) { + return t / _elm_lang$core$Time$second; +}; +var _elm_lang$core$Time$now = _elm_lang$core$Native_Time.now; +var _elm_lang$core$Time$onSelfMsg = F3( + function (router, interval, state) { + var _p7 = A2(_elm_lang$core$Dict$get, interval, state.taggers); + if (_p7.ctor === 'Nothing') { + return _elm_lang$core$Task$succeed(state); + } else { + var tellTaggers = function (time) { + return _elm_lang$core$Task$sequence( + A2( + _elm_lang$core$List$map, + function (tagger) { + return A2( + _elm_lang$core$Platform$sendToApp, + router, + tagger(time)); + }, + _p7._0)); + }; + return A2( + _elm_lang$core$Task$andThen, + function (_p8) { + return _elm_lang$core$Task$succeed(state); + }, + A2(_elm_lang$core$Task$andThen, tellTaggers, _elm_lang$core$Time$now)); + } + }); +var _elm_lang$core$Time$subscription = _elm_lang$core$Native_Platform.leaf('Time'); +var _elm_lang$core$Time$State = F2( + function (a, b) { + return {taggers: a, processes: b}; + }); +var _elm_lang$core$Time$init = _elm_lang$core$Task$succeed( + A2(_elm_lang$core$Time$State, _elm_lang$core$Dict$empty, _elm_lang$core$Dict$empty)); +var _elm_lang$core$Time$onEffects = F3( + function (router, subs, _p9) { + var _p10 = _p9; + var rightStep = F3( + function (_p12, id, _p11) { + var _p13 = _p11; + return { + ctor: '_Tuple3', + _0: _p13._0, + _1: _p13._1, + _2: A2( + _elm_lang$core$Task$andThen, + function (_p14) { + return _p13._2; + }, + _elm_lang$core$Native_Scheduler.kill(id)) + }; + }); + var bothStep = F4( + function (interval, taggers, id, _p15) { + var _p16 = _p15; + return { + ctor: '_Tuple3', + _0: _p16._0, + _1: A3(_elm_lang$core$Dict$insert, interval, id, _p16._1), + _2: _p16._2 + }; + }); + var leftStep = F3( + function (interval, taggers, _p17) { + var _p18 = _p17; + return { + ctor: '_Tuple3', + _0: {ctor: '::', _0: interval, _1: _p18._0}, + _1: _p18._1, + _2: _p18._2 + }; + }); + var newTaggers = A3(_elm_lang$core$List$foldl, _elm_lang$core$Time$addMySub, _elm_lang$core$Dict$empty, subs); + var _p19 = A6( + _elm_lang$core$Dict$merge, + leftStep, + bothStep, + rightStep, + newTaggers, + _p10.processes, + { + ctor: '_Tuple3', + _0: {ctor: '[]'}, + _1: _elm_lang$core$Dict$empty, + _2: _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}) + }); + var spawnList = _p19._0; + var existingDict = _p19._1; + var killTask = _p19._2; + return A2( + _elm_lang$core$Task$andThen, + function (newProcesses) { + return _elm_lang$core$Task$succeed( + A2(_elm_lang$core$Time$State, newTaggers, newProcesses)); + }, + A2( + _elm_lang$core$Task$andThen, + function (_p20) { + return A3(_elm_lang$core$Time$spawnHelp, router, spawnList, existingDict); + }, + killTask)); + }); +var _elm_lang$core$Time$Every = F2( + function (a, b) { + return {ctor: 'Every', _0: a, _1: b}; + }); +var _elm_lang$core$Time$every = F2( + function (interval, tagger) { + return _elm_lang$core$Time$subscription( + A2(_elm_lang$core$Time$Every, interval, tagger)); + }); +var _elm_lang$core$Time$subMap = F2( + function (f, _p21) { + var _p22 = _p21; + return A2( + _elm_lang$core$Time$Every, + _p22._0, + function (_p23) { + return f( + _p22._1(_p23)); + }); + }); +_elm_lang$core$Native_Platform.effectManagers['Time'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Time$init, onEffects: _elm_lang$core$Time$onEffects, onSelfMsg: _elm_lang$core$Time$onSelfMsg, tag: 'sub', subMap: _elm_lang$core$Time$subMap}; + var _elm_lang$core$Set$foldr = F3( function (f, b, _p0) { var _p1 = _p0; @@ -7209,418 +7635,6 @@ return { }; }(); -var _elm_lang$core$Task$onError = _elm_lang$core$Native_Scheduler.onError; -var _elm_lang$core$Task$andThen = _elm_lang$core$Native_Scheduler.andThen; -var _elm_lang$core$Task$spawnCmd = F2( - function (router, _p0) { - var _p1 = _p0; - return _elm_lang$core$Native_Scheduler.spawn( - A2( - _elm_lang$core$Task$andThen, - _elm_lang$core$Platform$sendToApp(router), - _p1._0)); - }); -var _elm_lang$core$Task$fail = _elm_lang$core$Native_Scheduler.fail; -var _elm_lang$core$Task$mapError = F2( - function (convert, task) { - return A2( - _elm_lang$core$Task$onError, - function (_p2) { - return _elm_lang$core$Task$fail( - convert(_p2)); - }, - task); - }); -var _elm_lang$core$Task$succeed = _elm_lang$core$Native_Scheduler.succeed; -var _elm_lang$core$Task$map = F2( - function (func, taskA) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return _elm_lang$core$Task$succeed( - func(a)); - }, - taskA); - }); -var _elm_lang$core$Task$map2 = F3( - function (func, taskA, taskB) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return _elm_lang$core$Task$succeed( - A2(func, a, b)); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map3 = F4( - function (func, taskA, taskB, taskC) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return _elm_lang$core$Task$succeed( - A3(func, a, b, c)); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map4 = F5( - function (func, taskA, taskB, taskC, taskD) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return A2( - _elm_lang$core$Task$andThen, - function (d) { - return _elm_lang$core$Task$succeed( - A4(func, a, b, c, d)); - }, - taskD); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map5 = F6( - function (func, taskA, taskB, taskC, taskD, taskE) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return A2( - _elm_lang$core$Task$andThen, - function (d) { - return A2( - _elm_lang$core$Task$andThen, - function (e) { - return _elm_lang$core$Task$succeed( - A5(func, a, b, c, d, e)); - }, - taskE); - }, - taskD); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$sequence = function (tasks) { - var _p3 = tasks; - if (_p3.ctor === '[]') { - return _elm_lang$core$Task$succeed( - {ctor: '[]'}); - } else { - return A3( - _elm_lang$core$Task$map2, - F2( - function (x, y) { - return {ctor: '::', _0: x, _1: y}; - }), - _p3._0, - _elm_lang$core$Task$sequence(_p3._1)); - } -}; -var _elm_lang$core$Task$onEffects = F3( - function (router, commands, state) { - return A2( - _elm_lang$core$Task$map, - function (_p4) { - return {ctor: '_Tuple0'}; - }, - _elm_lang$core$Task$sequence( - A2( - _elm_lang$core$List$map, - _elm_lang$core$Task$spawnCmd(router), - commands))); - }); -var _elm_lang$core$Task$init = _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}); -var _elm_lang$core$Task$onSelfMsg = F3( - function (_p7, _p6, _p5) { - return _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}); - }); -var _elm_lang$core$Task$command = _elm_lang$core$Native_Platform.leaf('Task'); -var _elm_lang$core$Task$Perform = function (a) { - return {ctor: 'Perform', _0: a}; -}; -var _elm_lang$core$Task$perform = F2( - function (toMessage, task) { - return _elm_lang$core$Task$command( - _elm_lang$core$Task$Perform( - A2(_elm_lang$core$Task$map, toMessage, task))); - }); -var _elm_lang$core$Task$attempt = F2( - function (resultToMessage, task) { - return _elm_lang$core$Task$command( - _elm_lang$core$Task$Perform( - A2( - _elm_lang$core$Task$onError, - function (_p8) { - return _elm_lang$core$Task$succeed( - resultToMessage( - _elm_lang$core$Result$Err(_p8))); - }, - A2( - _elm_lang$core$Task$andThen, - function (_p9) { - return _elm_lang$core$Task$succeed( - resultToMessage( - _elm_lang$core$Result$Ok(_p9))); - }, - task)))); - }); -var _elm_lang$core$Task$cmdMap = F2( - function (tagger, _p10) { - var _p11 = _p10; - return _elm_lang$core$Task$Perform( - A2(_elm_lang$core$Task$map, tagger, _p11._0)); - }); -_elm_lang$core$Native_Platform.effectManagers['Task'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Task$init, onEffects: _elm_lang$core$Task$onEffects, onSelfMsg: _elm_lang$core$Task$onSelfMsg, tag: 'cmd', cmdMap: _elm_lang$core$Task$cmdMap}; - -//import Native.Scheduler // - -var _elm_lang$core$Native_Time = function() { - -var now = _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) -{ - callback(_elm_lang$core$Native_Scheduler.succeed(Date.now())); -}); - -function setInterval_(interval, task) -{ - return _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) - { - var id = setInterval(function() { - _elm_lang$core$Native_Scheduler.rawSpawn(task); - }, interval); - - return function() { clearInterval(id); }; - }); -} - -return { - now: now, - setInterval_: F2(setInterval_) -}; - -}(); -var _elm_lang$core$Time$setInterval = _elm_lang$core$Native_Time.setInterval_; -var _elm_lang$core$Time$spawnHelp = F3( - function (router, intervals, processes) { - var _p0 = intervals; - if (_p0.ctor === '[]') { - return _elm_lang$core$Task$succeed(processes); - } else { - var _p1 = _p0._0; - var spawnRest = function (id) { - return A3( - _elm_lang$core$Time$spawnHelp, - router, - _p0._1, - A3(_elm_lang$core$Dict$insert, _p1, id, processes)); - }; - var spawnTimer = _elm_lang$core$Native_Scheduler.spawn( - A2( - _elm_lang$core$Time$setInterval, - _p1, - A2(_elm_lang$core$Platform$sendToSelf, router, _p1))); - return A2(_elm_lang$core$Task$andThen, spawnRest, spawnTimer); - } - }); -var _elm_lang$core$Time$addMySub = F2( - function (_p2, state) { - var _p3 = _p2; - var _p6 = _p3._1; - var _p5 = _p3._0; - var _p4 = A2(_elm_lang$core$Dict$get, _p5, state); - if (_p4.ctor === 'Nothing') { - return A3( - _elm_lang$core$Dict$insert, - _p5, - { - ctor: '::', - _0: _p6, - _1: {ctor: '[]'} - }, - state); - } else { - return A3( - _elm_lang$core$Dict$insert, - _p5, - {ctor: '::', _0: _p6, _1: _p4._0}, - state); - } - }); -var _elm_lang$core$Time$inMilliseconds = function (t) { - return t; -}; -var _elm_lang$core$Time$millisecond = 1; -var _elm_lang$core$Time$second = 1000 * _elm_lang$core$Time$millisecond; -var _elm_lang$core$Time$minute = 60 * _elm_lang$core$Time$second; -var _elm_lang$core$Time$hour = 60 * _elm_lang$core$Time$minute; -var _elm_lang$core$Time$inHours = function (t) { - return t / _elm_lang$core$Time$hour; -}; -var _elm_lang$core$Time$inMinutes = function (t) { - return t / _elm_lang$core$Time$minute; -}; -var _elm_lang$core$Time$inSeconds = function (t) { - return t / _elm_lang$core$Time$second; -}; -var _elm_lang$core$Time$now = _elm_lang$core$Native_Time.now; -var _elm_lang$core$Time$onSelfMsg = F3( - function (router, interval, state) { - var _p7 = A2(_elm_lang$core$Dict$get, interval, state.taggers); - if (_p7.ctor === 'Nothing') { - return _elm_lang$core$Task$succeed(state); - } else { - var tellTaggers = function (time) { - return _elm_lang$core$Task$sequence( - A2( - _elm_lang$core$List$map, - function (tagger) { - return A2( - _elm_lang$core$Platform$sendToApp, - router, - tagger(time)); - }, - _p7._0)); - }; - return A2( - _elm_lang$core$Task$andThen, - function (_p8) { - return _elm_lang$core$Task$succeed(state); - }, - A2(_elm_lang$core$Task$andThen, tellTaggers, _elm_lang$core$Time$now)); - } - }); -var _elm_lang$core$Time$subscription = _elm_lang$core$Native_Platform.leaf('Time'); -var _elm_lang$core$Time$State = F2( - function (a, b) { - return {taggers: a, processes: b}; - }); -var _elm_lang$core$Time$init = _elm_lang$core$Task$succeed( - A2(_elm_lang$core$Time$State, _elm_lang$core$Dict$empty, _elm_lang$core$Dict$empty)); -var _elm_lang$core$Time$onEffects = F3( - function (router, subs, _p9) { - var _p10 = _p9; - var rightStep = F3( - function (_p12, id, _p11) { - var _p13 = _p11; - return { - ctor: '_Tuple3', - _0: _p13._0, - _1: _p13._1, - _2: A2( - _elm_lang$core$Task$andThen, - function (_p14) { - return _p13._2; - }, - _elm_lang$core$Native_Scheduler.kill(id)) - }; - }); - var bothStep = F4( - function (interval, taggers, id, _p15) { - var _p16 = _p15; - return { - ctor: '_Tuple3', - _0: _p16._0, - _1: A3(_elm_lang$core$Dict$insert, interval, id, _p16._1), - _2: _p16._2 - }; - }); - var leftStep = F3( - function (interval, taggers, _p17) { - var _p18 = _p17; - return { - ctor: '_Tuple3', - _0: {ctor: '::', _0: interval, _1: _p18._0}, - _1: _p18._1, - _2: _p18._2 - }; - }); - var newTaggers = A3(_elm_lang$core$List$foldl, _elm_lang$core$Time$addMySub, _elm_lang$core$Dict$empty, subs); - var _p19 = A6( - _elm_lang$core$Dict$merge, - leftStep, - bothStep, - rightStep, - newTaggers, - _p10.processes, - { - ctor: '_Tuple3', - _0: {ctor: '[]'}, - _1: _elm_lang$core$Dict$empty, - _2: _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}) - }); - var spawnList = _p19._0; - var existingDict = _p19._1; - var killTask = _p19._2; - return A2( - _elm_lang$core$Task$andThen, - function (newProcesses) { - return _elm_lang$core$Task$succeed( - A2(_elm_lang$core$Time$State, newTaggers, newProcesses)); - }, - A2( - _elm_lang$core$Task$andThen, - function (_p20) { - return A3(_elm_lang$core$Time$spawnHelp, router, spawnList, existingDict); - }, - killTask)); - }); -var _elm_lang$core$Time$Every = F2( - function (a, b) { - return {ctor: 'Every', _0: a, _1: b}; - }); -var _elm_lang$core$Time$every = F2( - function (interval, tagger) { - return _elm_lang$core$Time$subscription( - A2(_elm_lang$core$Time$Every, interval, tagger)); - }); -var _elm_lang$core$Time$subMap = F2( - function (f, _p21) { - var _p22 = _p21; - return A2( - _elm_lang$core$Time$Every, - _p22._0, - function (_p23) { - return f( - _p22._1(_p23)); - }); - }); -_elm_lang$core$Native_Platform.effectManagers['Time'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Time$init, onEffects: _elm_lang$core$Time$onEffects, onSelfMsg: _elm_lang$core$Time$onSelfMsg, tag: 'sub', subMap: _elm_lang$core$Time$subMap}; - var _elm_lang$core$Date$millisecond = _elm_lang$core$Native_Date.millisecond; var _elm_lang$core$Date$second = _elm_lang$core$Native_Date.second; var _elm_lang$core$Date$minute = _elm_lang$core$Native_Date.minute; @@ -12396,42 +12410,6 @@ var _justinmimbs$elm_date_extra$Date_Extra_Facts$daysBeforeStartOfMonth = F2( } }); -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$toUnixTime = function (rd) { - return (rd - 719163) * _justinmimbs$elm_date_extra$Date_Extra_Facts$msPerDay; -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber = function (rd) { - var _p0 = A2(_elm_lang$core$Basics_ops['%'], rd, 7); - if (_p0 === 0) { - return 7; - } else { - return _p0; - } -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$leapYearsInCommonEra = function (y) { - return (((y / 4) | 0) - ((y / 100) | 0)) + ((y / 400) | 0); -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$rataDieBeforeStartOfYear = function (y) { - return (365 * (y - 1)) + _justinmimbs$elm_date_extra$Date_Internal_RataDie$leapYearsInCommonEra(y - 1); -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromOrdinalDate = F2( - function (y, d) { - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$rataDieBeforeStartOfYear(y) + d; - }); -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$week1Day1OfWeekYear = function (y) { - var jan4RD = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromOrdinalDate, y, 4); - return (jan4RD - _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber(jan4RD)) + 1; -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromWeekDate = F3( - function (y, w, d) { - var week1Day0RD = _justinmimbs$elm_date_extra$Date_Internal_RataDie$week1Day1OfWeekYear(y) - 1; - return (week1Day0RD + ((w - 1) * 7)) + d; - }); -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate = F3( - function (y, m, d) { - var md = A2(_justinmimbs$elm_date_extra$Date_Extra_Facts$daysBeforeStartOfMonth, y, m); - var yd = _justinmimbs$elm_date_extra$Date_Internal_RataDie$rataDieBeforeStartOfYear(y); - return (yd + md) + d; - }); var _justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt = F2( function (a, b) { return { @@ -12441,77 +12419,86 @@ var _justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt = F2( }; }); var _justinmimbs$elm_date_extra$Date_Internal_RataDie$year = function (rd) { - var _p1 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, rd, 146097); - var q400 = _p1._0; - var r400 = _p1._1; - var _p2 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r400, 36524); - var q100 = _p2._0; - var r100 = _p2._1; - var _p3 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r100, 1461); - var q4 = _p3._0; - var r4 = _p3._1; - var _p4 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r4, 365); - var q1 = _p4._0; - var r1 = _p4._1; + var _p0 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, rd, 146097); + var n400 = _p0._0; + var r400 = _p0._1; + var _p1 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r400, 36524); + var n100 = _p1._0; + var r100 = _p1._1; + var _p2 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r100, 1461); + var n4 = _p2._0; + var r4 = _p2._1; + var _p3 = A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$divideInt, r4, 365); + var n1 = _p3._0; + var r1 = _p3._1; var n = _elm_lang$core$Native_Utils.eq(r1, 0) ? 0 : 1; - return ((((q400 * 400) + (q100 * 100)) + (q4 * 4)) + q1) + n; + return ((((n400 * 400) + (n100 * 100)) + (n4 * 4)) + n1) + n; }; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$ordinalDay = function (rd) { - return rd - _justinmimbs$elm_date_extra$Date_Internal_RataDie$rataDieBeforeStartOfYear( - _justinmimbs$elm_date_extra$Date_Internal_RataDie$year(rd)); +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber = function (rd) { + var _p4 = A2(_elm_lang$core$Basics_ops['%'], rd, 7); + if (_p4 === 0) { + return 7; + } else { + return _p4; + } +}; +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeYear = function (y1) { + var y = y1 - 1; + var leapYears = (((y / 4) | 0) - ((y / 100) | 0)) + ((y / 400) | 0); + return (365 * y) + leapYears; +}; +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeWeekYear = function (y) { + var jan4 = _justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeYear(y) + 4; + return jan4 - _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber(jan4); }; var _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekYear = function (rd) { - var daysToThursday = 4 - _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber(rd); - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$year(rd + daysToThursday); + return _justinmimbs$elm_date_extra$Date_Internal_RataDie$year( + rd + (4 - _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekdayNumber(rd))); }; var _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekNumber = function (rd) { - var week1Day1RD = _justinmimbs$elm_date_extra$Date_Internal_RataDie$week1Day1OfWeekYear( - _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekYear(rd)); - return (((rd - week1Day1RD) / 7) | 0) + 1; + var week1Day1 = _justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeWeekYear( + _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekYear(rd)) + 1; + return (((rd - week1Day1) / 7) | 0) + 1; }; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$find = F2( - function (pred, list) { - find: - while (true) { - var _p5 = list; - if (_p5.ctor === '[]') { - return _elm_lang$core$Maybe$Nothing; - } else { - var _p6 = _p5._0; - if (pred(_p6)) { - return _elm_lang$core$Maybe$Just(_p6); - } else { - var _v2 = pred, - _v3 = _p5._1; - pred = _v2; - list = _v3; - continue find; - } - } - } +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromWeekDate = F3( + function (wy, wn, wdn) { + return (_justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeWeekYear(wy) + ((wn - 1) * 7)) + wdn; + }); +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate = F3( + function (y, m, d) { + return (_justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeYear(y) + A2(_justinmimbs$elm_date_extra$Date_Extra_Facts$daysBeforeStartOfMonth, y, m)) + d; + }); +var _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromOrdinalDate = F2( + function (y, od) { + return _justinmimbs$elm_date_extra$Date_Internal_RataDie$daysBeforeYear(y) + od; }); -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$month = function (rd) { - var od = _justinmimbs$elm_date_extra$Date_Internal_RataDie$ordinalDay(rd); - var y = _justinmimbs$elm_date_extra$Date_Internal_RataDie$year(rd); - return A2( - _elm_lang$core$Maybe$withDefault, - _elm_lang$core$Date$Jan, - A2( - _justinmimbs$elm_date_extra$Date_Internal_RataDie$find, - function (m) { - return _elm_lang$core$Native_Utils.cmp( - A2(_justinmimbs$elm_date_extra$Date_Extra_Facts$daysBeforeStartOfMonth, y, m), - od) < 0; - }, - _elm_lang$core$List$reverse(_justinmimbs$elm_date_extra$Date_Extra_Facts$months))); -}; -var _justinmimbs$elm_date_extra$Date_Internal_RataDie$day = function (rd) { - var od = _justinmimbs$elm_date_extra$Date_Internal_RataDie$ordinalDay(rd); - var m = _justinmimbs$elm_date_extra$Date_Internal_RataDie$month(rd); - var y = _justinmimbs$elm_date_extra$Date_Internal_RataDie$year(rd); - return od - A2(_justinmimbs$elm_date_extra$Date_Extra_Facts$daysBeforeStartOfMonth, y, m); -}; +var _justinmimbs$elm_date_extra$Date_Internal_Core$msFromTimeParts = F4( + function (hh, mm, ss, ms) { + return (((_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerHour * hh) + (_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerMinute * mm)) + (_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerSecond * ss)) + ms; + }); +var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromRataDie = function (rd) { + return (rd - 719163) * _justinmimbs$elm_date_extra$Date_Extra_Facts$msPerDay; +}; +var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromOrdinalDate = F2( + function (y, d) { + return _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromRataDie( + A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromOrdinalDate, y, d)); + }); +var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromCalendarDate = F3( + function (y, m, d) { + return _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromRataDie( + A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate, y, m, d)); + }); +var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromParts = F7( + function (y, m, d, hh, mm, ss, ms) { + return A3(_justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromCalendarDate, y, m, d) + A4(_justinmimbs$elm_date_extra$Date_Internal_Core$msFromTimeParts, hh, mm, ss, ms); + }); +var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromWeekDate = F3( + function (y, w, d) { + return _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromRataDie( + A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromWeekDate, y, w, d)); + }); var _justinmimbs$elm_date_extra$Date_Internal_Core$weekNumberFromCalendarDate = F3( function (y, m, d) { return _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekNumber( @@ -12522,30 +12509,6 @@ var _justinmimbs$elm_date_extra$Date_Internal_Core$weekYearFromCalendarDate = F3 return _justinmimbs$elm_date_extra$Date_Internal_RataDie$weekYear( A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate, y, m, d)); }); -var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromOrdinalDate = F2( - function (y, d) { - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$toUnixTime( - A2(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromOrdinalDate, y, d)); - }); -var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromWeekDate = F3( - function (y, w, d) { - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$toUnixTime( - A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromWeekDate, y, w, d)); - }); -var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromCalendarDate = F3( - function (y, m, d) { - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$toUnixTime( - A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate, y, m, d)); - }); -var _justinmimbs$elm_date_extra$Date_Internal_Core$msFromTimeParts = F4( - function (hh, mm, ss, ms) { - return ((ms + (_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerSecond * ss)) + (_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerMinute * mm)) + (_justinmimbs$elm_date_extra$Date_Extra_Facts$msPerHour * hh); - }); -var _justinmimbs$elm_date_extra$Date_Internal_Core$unixTimeFromParts = F7( - function (y, m, d, hh, mm, ss, ms) { - return _justinmimbs$elm_date_extra$Date_Internal_RataDie$toUnixTime( - A3(_justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate, y, m, d)) + A4(_justinmimbs$elm_date_extra$Date_Internal_Core$msFromTimeParts, hh, mm, ss, ms); - }); var _justinmimbs$elm_date_extra$Date_Internal_Extract$msOffsetFromUtc = function (date) { var utcTime = _elm_lang$core$Date$toTime(date); @@ -13394,6 +13357,13 @@ var _justinmimbs$elm_date_extra$Date_Internal_Parse$offsetTimeFromIsoString = fu s)))); }; +var _justinmimbs$elm_date_extra$Date_Extra$toRataDie = function (date) { + return A3( + _justinmimbs$elm_date_extra$Date_Internal_RataDie$fromCalendarDate, + _elm_lang$core$Date$year(date), + _elm_lang$core$Date$month(date), + _elm_lang$core$Date$day(date)); +}; var _justinmimbs$elm_date_extra$Date_Extra$toParts = function (date) { return { ctor: '_Tuple7', @@ -13580,25 +13550,25 @@ var _justinmimbs$elm_date_extra$Date_Extra$add = F3( } }); var _justinmimbs$elm_date_extra$Date_Extra$rangeHelp = F5( - function (result, interval, step, start, date) { + function (interval, step, end, revList, date) { rangeHelp: while (true) { if (_elm_lang$core$Native_Utils.cmp( _elm_lang$core$Date$toTime(date), - _elm_lang$core$Date$toTime(start)) < 0) { - return result; - } else { - var _v4 = {ctor: '::', _0: date, _1: result}, - _v5 = interval, - _v6 = step, - _v7 = start, + _elm_lang$core$Date$toTime(end)) < 0) { + var _v4 = interval, + _v5 = step, + _v6 = end, + _v7 = {ctor: '::', _0: date, _1: revList}, _v8 = A3(_justinmimbs$elm_date_extra$Date_Extra$add, interval, step, date); - result = _v4; - interval = _v5; - step = _v6; - start = _v7; + interval = _v4; + step = _v5; + end = _v6; + revList = _v7; date = _v8; continue rangeHelp; + } else { + return _elm_lang$core$List$reverse(revList); } } }); @@ -13702,18 +13672,16 @@ var _justinmimbs$elm_date_extra$Date_Extra$ceiling = F2( }); var _justinmimbs$elm_date_extra$Date_Extra$range = F4( function (interval, step, start, end) { - var stepBack = _elm_lang$core$Basics$negate( - A2(_elm_lang$core$Basics$max, 1, step)); - return A5( + var first = A2(_justinmimbs$elm_date_extra$Date_Extra$ceiling, interval, start); + return (_elm_lang$core$Native_Utils.cmp( + _elm_lang$core$Date$toTime(first), + _elm_lang$core$Date$toTime(end)) < 0) ? A5( _justinmimbs$elm_date_extra$Date_Extra$rangeHelp, - {ctor: '[]'}, interval, - stepBack, - start, - A2( - _justinmimbs$elm_date_extra$Date_Extra$ceiling, - interval, - A3(_justinmimbs$elm_date_extra$Date_Extra$add, interval, stepBack, end))); + A2(_elm_lang$core$Basics$max, 1, step), + end, + {ctor: '[]'}, + first) : {ctor: '[]'}; }); var _justinmimbs$elm_date_extra$Date_Extra$fromIsoString = function (_p11) { return A2( @@ -13811,6 +13779,13 @@ var _justinmimbs$elm_date_extra$Date_Extra$diff = F3( A2(_justinmimbs$elm_date_extra$Date_Extra$floor, _p19, date2)) / 7) | 0; } }); +var _justinmimbs$elm_date_extra$Date_Extra$fromRataDie = function (rd) { + return A3( + _justinmimbs$elm_date_extra$Date_Extra$add, + _justinmimbs$elm_date_extra$Date_Extra$Day, + rd - 719163, + A3(_justinmimbs$elm_date_extra$Date_Extra$fromCalendarDate, 1970, _elm_lang$core$Date$Jan, 1)); +}; var _justinmimbs$elm_date_extra$Date_Extra$Hour = {ctor: 'Hour'}; var _justinmimbs$elm_date_extra$Date_Extra$Minute = {ctor: 'Minute'}; var _justinmimbs$elm_date_extra$Date_Extra$equalBy = F3( @@ -13879,6 +13854,8 @@ var _user$project$Models$unpackFilterType = function (filter) { return {ctor: '_Tuple2', _0: _p0._0, _1: _p0._1}; case 'LocationFilter': return {ctor: '_Tuple2', _0: _p0._0, _1: _p0._1}; + case 'VideoFilter': + return {ctor: '_Tuple2', _0: _p0._0, _1: _p0._1}; default: return {ctor: '_Tuple2', _0: _p0._0, _1: _p0._1}; } @@ -13905,7 +13882,9 @@ var _user$project$Models$Model = function (a) { return function (i) { return function (j) { return function (k) { - return {days: a, events: b, eventInstances: c, eventLocations: d, eventTypes: e, speakers: f, flags: g, filter: h, location: i, route: j, dataLoaded: k}; + return function (l) { + return {days: a, events: b, eventInstances: c, eventLocations: d, eventTypes: e, eventTracks: f, speakers: g, flags: h, filter: i, location: j, route: k, dataLoaded: l}; + }; }; }; }; @@ -13921,9 +13900,9 @@ var _user$project$Models$Day = F3( function (a, b, c) { return {day_name: a, date: b, repr: c}; }); -var _user$project$Models$Speaker = F5( - function (a, b, c, d, e) { - return {name: a, slug: b, biography: c, largePictureUrl: d, smallPictureUrl: e}; +var _user$project$Models$Speaker = F3( + function (a, b, c) { + return {name: a, slug: b, biography: c}; }); var _user$project$Models$EventInstance = function (a) { return function (b) { @@ -13941,7 +13920,9 @@ var _user$project$Models$EventInstance = function (a) { return function (n) { return function (o) { return function (p) { - return {title: a, slug: b, id: c, url: d, eventSlug: e, eventType: f, backgroundColor: g, forgroundColor: h, from: i, to: j, timeslots: k, location: l, locationIcon: m, videoState: n, videoUrl: o, isFavorited: p}; + return function (q) { + return {title: a, slug: b, id: c, url: d, eventSlug: e, eventType: f, eventTrack: g, backgroundColor: h, forgroundColor: i, from: j, to: k, timeslots: l, location: m, locationIcon: n, videoState: o, videoUrl: p, isFavorited: q}; + }; }; }; }; @@ -13966,9 +13947,9 @@ var _user$project$Models$Flags = F5( function (a, b, c, d, e) { return {schedule_timeslot_length_minutes: a, schedule_midnight_offset_hours: b, ics_button_href: c, camp_slug: d, websocket_server: e}; }); -var _user$project$Models$Filter = F3( - function (a, b, c) { - return {eventTypes: a, eventLocations: b, videoRecording: c}; +var _user$project$Models$Filter = F4( + function (a, b, c, d) { + return {eventTypes: a, eventLocations: b, eventTracks: c, videoRecording: d}; }); var _user$project$Models$NotFoundRoute = {ctor: 'NotFoundRoute'}; var _user$project$Models$SpeakerRoute = function (a) { @@ -13984,6 +13965,10 @@ var _user$project$Models$OverviewFilteredRoute = function (a) { return {ctor: 'OverviewFilteredRoute', _0: a}; }; var _user$project$Models$OverviewRoute = {ctor: 'OverviewRoute'}; +var _user$project$Models$TrackFilter = F2( + function (a, b) { + return {ctor: 'TrackFilter', _0: a, _1: b}; + }); var _user$project$Models$VideoFilter = F2( function (a, b) { return {ctor: 'VideoFilter', _0: a, _1: b}; @@ -13997,6 +13982,15 @@ var _user$project$Models$TypeFilter = F4( return {ctor: 'TypeFilter', _0: a, _1: b, _2: c, _3: d}; }); +var _user$project$Decoders$eventTrackDecoder = A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'slug', + _elm_lang$core$Json_Decode$string, + A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'name', + _elm_lang$core$Json_Decode$string, + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$TrackFilter))); var _user$project$Decoders$eventTypeDecoder = A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, 'light_text', @@ -14080,29 +14074,33 @@ var _user$project$Decoders$eventInstanceDecoder = A4( _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'event_type', + 'event_track', _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'event_slug', + 'event_type', _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'url', + 'event_slug', _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'id', - _elm_lang$core$Json_Decode$int, + 'url', + _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'slug', - _elm_lang$core$Json_Decode$string, + 'id', + _elm_lang$core$Json_Decode$int, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'title', + 'slug', _elm_lang$core$Json_Decode$string, - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$EventInstance))))))))))))))))); + A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'title', + _elm_lang$core$Json_Decode$string, + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$EventInstance)))))))))))))))))); var _user$project$Decoders$eventDecoder = A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, 'event_type', @@ -14133,29 +14131,19 @@ var _user$project$Decoders$eventDecoder = A3( 'title', _elm_lang$core$Json_Decode$string, _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$Event)))))))); -var _user$project$Decoders$speakerDecoder = A4( - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$optional, - 'small_picture_url', - _elm_lang$core$Json_Decode$nullable(_elm_lang$core$Json_Decode$string), - _elm_lang$core$Maybe$Nothing, - A4( - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$optional, - 'large_picture_url', - _elm_lang$core$Json_Decode$nullable(_elm_lang$core$Json_Decode$string), - _elm_lang$core$Maybe$Nothing, +var _user$project$Decoders$speakerDecoder = A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'biography', + _elm_lang$core$Json_Decode$string, + A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'slug', + _elm_lang$core$Json_Decode$string, A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'biography', + 'name', _elm_lang$core$Json_Decode$string, - A3( - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'slug', - _elm_lang$core$Json_Decode$string, - A3( - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'name', - _elm_lang$core$Json_Decode$string, - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$Speaker)))))); + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$Speaker)))); var _user$project$Decoders$dayDecoder = A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, 'repr', @@ -14175,25 +14163,29 @@ var _user$project$Decoders$initDataDecoder = A3( _elm_lang$core$Json_Decode$list(_user$project$Decoders$speakerDecoder), A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'event_types', - _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventTypeDecoder), + 'event_tracks', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventTrackDecoder), A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'event_locations', - _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventLocationDecoder), + 'event_types', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventTypeDecoder), A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'event_instances', - _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventInstanceDecoder), + 'event_locations', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventLocationDecoder), A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'events', - _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventDecoder), + 'event_instances', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventInstanceDecoder), A3( _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, - 'days', - _elm_lang$core$Json_Decode$list(_user$project$Decoders$dayDecoder), - _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$Model))))))); + 'events', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$eventDecoder), + A3( + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required, + 'days', + _elm_lang$core$Json_Decode$list(_user$project$Decoders$dayDecoder), + _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$decode(_user$project$Models$Model)))))))); var _user$project$Decoders$WebSocketAction = function (a) { return {action: a}; }; @@ -14436,9 +14428,9 @@ var _user$project$Views_FilterView$filterChoiceView = F4( case 'has-recording': return 'film'; case 'to-be-recorded': - return 'video-camera'; + return 'video'; case 'not-to-be-recorded': - return 'ban'; + return 'video-slash'; default: return ''; } @@ -14709,9 +14701,10 @@ var _user$project$Views_FilterView$videoRecordingFilters = { var _user$project$Views_FilterView$parseFilterFromQuery = F2( function (query, model) { var videoFilters = A3(_user$project$Views_FilterView$getFilter, 'video', _user$project$Views_FilterView$videoRecordingFilters, query); + var tracks = A3(_user$project$Views_FilterView$getFilter, 'tracks', model.eventTracks, query); var locations = A3(_user$project$Views_FilterView$getFilter, 'location', model.eventLocations, query); var types = A3(_user$project$Views_FilterView$getFilter, 'type', model.eventTypes, query); - return {eventTypes: types, eventLocations: locations, videoRecording: videoFilters}; + return {eventTypes: types, eventLocations: locations, eventTracks: tracks, videoRecording: videoFilters}; }); var _user$project$Views_FilterView$icsButton = function (model) { var filterString = function () { @@ -14835,14 +14828,26 @@ var _user$project$Views_FilterView$filterSidebar = function (model) { ctor: '::', _0: A5( _user$project$Views_FilterView$filterView, - 'Video', - _user$project$Views_FilterView$videoRecordingFilters, - model.filter.videoRecording, + 'Track', + model.eventTracks, + model.filter.eventTracks, model.eventInstances, function (_) { - return _.videoState; + return _.eventTrack; }), - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: A5( + _user$project$Views_FilterView$filterView, + 'Video', + _user$project$Views_FilterView$videoRecordingFilters, + model.filter.videoRecording, + model.eventInstances, + function (_) { + return _.videoState; + }), + _1: {ctor: '[]'} + } } } }), @@ -14865,11 +14870,12 @@ var _user$project$Views_FilterView$applyFilters = F2( }); var types = A2(slugs, model.eventTypes, model.filter.eventTypes); var locations = A2(slugs, model.eventLocations, model.filter.eventLocations); + var tracks = A2(slugs, model.eventTracks, model.filter.eventTracks); var videoFilters = A2(slugs, _user$project$Views_FilterView$videoRecordingFilters, model.filter.videoRecording); var filteredEventInstances = A2( _elm_lang$core$List$filter, function (eventInstance) { - return A3(_justinmimbs$elm_date_extra$Date_Extra$equalBy, _justinmimbs$elm_date_extra$Date_Extra$Month, eventInstance.from, day.date) && (A3(_justinmimbs$elm_date_extra$Date_Extra$equalBy, _justinmimbs$elm_date_extra$Date_Extra$Day, eventInstance.from, day.date) && (A2(_elm_lang$core$List$member, eventInstance.location, locations) && (A2(_elm_lang$core$List$member, eventInstance.eventType, types) && A2(_elm_lang$core$List$member, eventInstance.videoState, videoFilters)))); + return A3(_justinmimbs$elm_date_extra$Date_Extra$equalBy, _justinmimbs$elm_date_extra$Date_Extra$Month, eventInstance.from, day.date) && (A3(_justinmimbs$elm_date_extra$Date_Extra$equalBy, _justinmimbs$elm_date_extra$Date_Extra$Day, eventInstance.from, day.date) && (A2(_elm_lang$core$List$member, eventInstance.location, locations) && (A2(_elm_lang$core$List$member, eventInstance.eventType, types) && (A2(_elm_lang$core$List$member, eventInstance.eventTrack, tracks) && A2(_elm_lang$core$List$member, eventInstance.videoState, videoFilters))))); }, model.eventInstances); return filteredEventInstances; @@ -14939,7 +14945,7 @@ var _user$project$Update$update = F2( }, model.filter.eventLocations) : {ctor: '::', _0: eventLocation, _1: model.filter.eventLocations} }); - default: + case 'VideoFilter': var videoRecording = A2(_user$project$Models$VideoFilter, _p6._0, _p6._1); return _elm_lang$core$Native_Utils.update( currentFilter, @@ -14951,6 +14957,18 @@ var _user$project$Update$update = F2( }, model.filter.videoRecording) : {ctor: '::', _0: videoRecording, _1: model.filter.videoRecording} }); + default: + var eventTrack = A2(_user$project$Models$TrackFilter, _p6._0, _p6._1); + return _elm_lang$core$Native_Utils.update( + currentFilter, + { + eventTracks: A2(_elm_lang$core$List$member, eventTrack, model.filter.eventTracks) ? A2( + _elm_lang$core$List$filter, + function (x) { + return !_elm_lang$core$Native_Utils.eq(x, eventTrack); + }, + model.filter.videoRecording) : {ctor: '::', _0: eventTrack, _1: model.filter.eventTracks} + }); } }(); var query = _user$project$Views_FilterView$filterToQuery(newFilter); @@ -15536,7 +15554,7 @@ var _user$project$Views_DayView$locationColumns = F4( _0: _elm_lang$html$Html_Attributes$classList( { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'col-sm-11', _1: true}, + _0: {ctor: '_Tuple2', _0: 'col-xs-11', _1: true}, _1: {ctor: '[]'} }), _1: {ctor: '[]'} @@ -15591,7 +15609,7 @@ var _user$project$Views_DayView$gutter = function (hours) { _0: _elm_lang$html$Html_Attributes$classList( { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'col-sm-1', _1: true}, + _0: {ctor: '_Tuple2', _0: 'col-xs-1', _1: true}, _1: { ctor: '::', _0: {ctor: '_Tuple2', _0: 'day-view-gutter', _1: true}, @@ -15663,50 +15681,6 @@ var _user$project$Views_DayView$dayView = F2( }); }); -var _user$project$Views_EventDetail$eventInstanceItem = function (eventInstance) { - var toFormat = _elm_lang$core$Native_Utils.eq( - _elm_lang$core$Date$day(eventInstance.from), - _elm_lang$core$Date$day(eventInstance.to)) ? 'HH:mm' : 'E HH:mm'; - return A2( - _elm_lang$html$Html$li, - {ctor: '[]'}, - { - ctor: '::', - _0: _elm_lang$html$Html$text( - A2( - _elm_lang$core$Basics_ops['++'], - A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'E HH:mm', eventInstance.from), - A2( - _elm_lang$core$Basics_ops['++'], - ' to ', - A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, toFormat, eventInstance.to)))), - _1: {ctor: '[]'} - }); -}; -var _user$project$Views_EventDetail$eventInstancesSidebar = function (eventInstances) { - return A2( - _elm_lang$html$Html$div, - {ctor: '[]'}, - { - ctor: '::', - _0: A2( - _elm_lang$html$Html$h4, - {ctor: '[]'}, - { - ctor: '::', - _0: _elm_lang$html$Html$text('This event will occur at:'), - _1: {ctor: '[]'} - }), - _1: { - ctor: '::', - _0: A2( - _elm_lang$html$Html$ul, - {ctor: '[]'}, - A2(_elm_lang$core$List$map, _user$project$Views_EventDetail$eventInstanceItem, eventInstances)), - _1: {ctor: '[]'} - } - }); -}; var _user$project$Views_EventDetail$speakerDetail = function (speaker) { return A2( _elm_lang$html$Html$li, @@ -15754,67 +15728,173 @@ var _user$project$Views_EventDetail$speakerSidebar = function (speakers) { } }); }; -var _user$project$Views_EventDetail$eventMetaDataSidebar = function (event) { - var _p0 = function () { - var _p1 = event.videoState; - switch (_p1) { - case 'to-be-recorded': - return {ctor: '_Tuple2', _0: true, _1: 'Yes'}; - case 'not-to-be-recorded': - return {ctor: '_Tuple2', _0: true, _1: 'No'}; - default: - return {ctor: '_Tuple2', _0: false, _1: ''}; - } - }(); - var showVideoRecoring = _p0._0; - var videoRecording = _p0._1; - return A2( - _elm_lang$html$Html$div, - {ctor: '[]'}, - { +var _user$project$Views_EventDetail$eventInstanceItem = F2( + function (eventInstance, model) { + var _p0 = A2( + _elm_lang$core$Maybe$withDefault, + {ctor: '_Tuple2', _0: 'Unknown', _1: ''}, + _elm_lang$core$List$head( + A2( + _elm_lang$core$List$filter, + function (_p1) { + var _p2 = _p1; + return _elm_lang$core$Native_Utils.eq(_p2._1, eventInstance.location); + }, + A2(_elm_lang$core$List$map, _user$project$Models$unpackFilterType, model.eventLocations)))); + var locationName = _p0._0; + var toFormat = _elm_lang$core$Native_Utils.eq( + _elm_lang$core$Date$day(eventInstance.from), + _elm_lang$core$Date$day(eventInstance.to)) ? 'HH:mm' : 'E HH:mm'; + return { ctor: '::', _0: A2( - _elm_lang$html$Html$h4, + _elm_lang$html$Html$p, {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text('Metadata'), - _1: {ctor: '[]'} + _0: A2( + _elm_lang$html$Html$strong, + {ctor: '[]'}, + { + ctor: '::', + _0: _elm_lang$html$Html$text('When: '), + _1: {ctor: '[]'} + }), + _1: { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2( + _elm_lang$core$Basics_ops['++'], + A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, 'E HH:mm', eventInstance.from), + A2( + _elm_lang$core$Basics_ops['++'], + ' to ', + A2(_justinmimbs$elm_date_extra$Date_Extra$toFormattedString, toFormat, eventInstance.to)))), + _1: {ctor: '[]'} + } }), _1: { ctor: '::', _0: A2( - _elm_lang$html$Html$ul, + _elm_lang$html$Html$p, {ctor: '[]'}, - A2( - _elm_lang$core$Basics_ops['++'], + { + ctor: '::', + _0: A2( + _elm_lang$html$Html$strong, + {ctor: '[]'}, + { + ctor: '::', + _0: _elm_lang$html$Html$text('Where: '), + _1: {ctor: '[]'} + }), + _1: { + ctor: '::', + _0: _elm_lang$html$Html$text( + A2(_elm_lang$core$Basics_ops['++'], locationName, ' ')), + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$i, + { + ctor: '::', + _0: _elm_lang$html$Html_Attributes$classList( + { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'fa', _1: true}, + _1: { + ctor: '::', + _0: { + ctor: '_Tuple2', + _0: A2(_elm_lang$core$Basics_ops['++'], 'fa-', eventInstance.locationIcon), + _1: true + }, + _1: {ctor: '[]'} + } + }), + _1: {ctor: '[]'} + }, + {ctor: '[]'}), + _1: {ctor: '[]'} + } + } + }), + _1: {ctor: '[]'} + } + }; + }); +var _user$project$Views_EventDetail$eventMetaDataSidebar = F3( + function (event, eventInstances, model) { + var eventInstanceMetaData = function () { + var _p3 = eventInstances; + if ((_p3.ctor === '::') && (_p3._1.ctor === '[]')) { + return A2(_user$project$Views_EventDetail$eventInstanceItem, _p3._0, model); + } else { + return { + ctor: '::', + _0: A2( + _elm_lang$html$Html$h4, + {ctor: '[]'}, { ctor: '::', - _0: A2( - _elm_lang$html$Html$li, - {ctor: '[]'}, - { - ctor: '::', - _0: A2( - _elm_lang$html$Html$strong, - {ctor: '[]'}, - { - ctor: '::', - _0: _elm_lang$html$Html$text('Type: '), - _1: {ctor: '[]'} - }), - _1: { - ctor: '::', - _0: _elm_lang$html$Html$text(event.eventType), - _1: {ctor: '[]'} - } - }), + _0: _elm_lang$html$Html$text('Multiple occurences:'), _1: {ctor: '[]'} - }, - function () { - var _p2 = showVideoRecoring; - if (_p2 === true) { - return { + }), + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$ul, + {ctor: '[]'}, + A2( + _elm_lang$core$List$map, + function (ei) { + return A2( + _elm_lang$html$Html$li, + {ctor: '[]'}, + A2(_user$project$Views_EventDetail$eventInstanceItem, ei, model)); + }, + _p3)), + _1: {ctor: '[]'} + } + }; + } + }(); + var _p4 = function () { + var _p5 = event.videoState; + switch (_p5) { + case 'to-be-recorded': + return {ctor: '_Tuple2', _0: true, _1: 'Yes'}; + case 'not-to-be-recorded': + return {ctor: '_Tuple2', _0: true, _1: 'No'}; + default: + return {ctor: '_Tuple2', _0: false, _1: ''}; + } + }(); + var showVideoRecoring = _p4._0; + var videoRecording = _p4._1; + return A2( + _elm_lang$html$Html$div, + {ctor: '[]'}, + A2( + _elm_lang$core$Basics_ops['++'], + { + ctor: '::', + _0: A2( + _elm_lang$html$Html$h4, + {ctor: '[]'}, + { + ctor: '::', + _0: _elm_lang$html$Html$text('Metadata'), + _1: {ctor: '[]'} + }), + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$ul, + {ctor: '[]'}, + A2( + _elm_lang$core$Basics_ops['++'], + { ctor: '::', _0: A2( _elm_lang$html$Html$li, @@ -15826,47 +15906,74 @@ var _user$project$Views_EventDetail$eventMetaDataSidebar = function (event) { {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text('Recording: '), + _0: _elm_lang$html$Html$text('Type: '), _1: {ctor: '[]'} }), _1: { ctor: '::', - _0: _elm_lang$html$Html$text(videoRecording), + _0: _elm_lang$html$Html$text(event.eventType), _1: {ctor: '[]'} } }), _1: {ctor: '[]'} - }; - } else { - return {ctor: '[]'}; - } - }())), - _1: {ctor: '[]'} - } - }); -}; + }, + function () { + var _p6 = showVideoRecoring; + if (_p6 === true) { + return { + ctor: '::', + _0: A2( + _elm_lang$html$Html$li, + {ctor: '[]'}, + { + ctor: '::', + _0: A2( + _elm_lang$html$Html$strong, + {ctor: '[]'}, + { + ctor: '::', + _0: _elm_lang$html$Html$text('Recording: '), + _1: {ctor: '[]'} + }), + _1: { + ctor: '::', + _0: _elm_lang$html$Html$text(videoRecording), + _1: {ctor: '[]'} + } + }), + _1: {ctor: '[]'} + }; + } else { + return {ctor: '[]'}; + } + }())), + _1: {ctor: '[]'} + } + }, + eventInstanceMetaData)); + }); var _user$project$Views_EventDetail$getSpeakersFromSlugs = F3( function (speakers, slugs, collectedSpeakers) { getSpeakersFromSlugs: while (true) { - var _p3 = speakers; - if (_p3.ctor === '[]') { + var _p7 = speakers; + if (_p7.ctor === '[]') { return collectedSpeakers; } else { - var _p7 = _p3._0; + var _p11 = _p7._0; var foundSlug = _elm_lang$core$List$head( A2( _elm_lang$core$List$filter, function (slug) { - return _elm_lang$core$Native_Utils.eq(slug, _p7.slug); + return _elm_lang$core$Native_Utils.eq(slug, _p11.slug); }, slugs)); var foundSpeaker = function () { - var _p4 = foundSlug; - if (_p4.ctor === 'Just') { + var _p8 = foundSlug; + if (_p8.ctor === 'Just') { return { ctor: '::', - _0: _p7, + _0: _p11, _1: {ctor: '[]'} }; } else { @@ -15875,28 +15982,28 @@ var _user$project$Views_EventDetail$getSpeakersFromSlugs = F3( }(); var newCollectedSpeakers = A2(_elm_lang$core$Basics_ops['++'], collectedSpeakers, foundSpeaker); var newSlugs = function () { - var _p5 = foundSlug; - if (_p5.ctor === 'Just') { + var _p9 = foundSlug; + if (_p9.ctor === 'Just') { return A2( _elm_lang$core$List$filter, function (x) { - return !_elm_lang$core$Native_Utils.eq(x, _p5._0); + return !_elm_lang$core$Native_Utils.eq(x, _p9._0); }, slugs); } else { return slugs; } }(); - var _p6 = slugs; - if (_p6.ctor === '[]') { + var _p10 = slugs; + if (_p10.ctor === '[]') { return collectedSpeakers; } else { - var _v6 = _p3._1, - _v7 = newSlugs, - _v8 = newCollectedSpeakers; - speakers = _v6; - slugs = _v7; - collectedSpeakers = _v8; + var _v8 = _p7._1, + _v9 = newSlugs, + _v10 = newCollectedSpeakers; + speakers = _v8; + slugs = _v9; + collectedSpeakers = _v10; continue getSpeakersFromSlugs; } } @@ -15916,8 +16023,8 @@ var _user$project$Views_EventDetail$eventDetailSidebar = F2( }, model.eventInstances); var videoRecordingLink = function () { - var _p8 = event.videoUrl; - if (_p8.ctor === 'Nothing') { + var _p12 = event.videoUrl; + if (_p12.ctor === 'Nothing') { return {ctor: '[]'}; } else { return { @@ -15926,7 +16033,7 @@ var _user$project$Views_EventDetail$eventDetailSidebar = F2( _elm_lang$html$Html$a, { ctor: '::', - _0: _elm_lang$html$Html_Attributes$href(_p8._0), + _0: _elm_lang$html$Html_Attributes$href(_p12._0), _1: { ctor: '::', _0: _elm_lang$html$Html_Attributes$classList( @@ -15991,12 +16098,8 @@ var _user$project$Views_EventDetail$eventDetailSidebar = F2( _0: _user$project$Views_EventDetail$speakerSidebar(speakers), _1: { ctor: '::', - _0: _user$project$Views_EventDetail$eventMetaDataSidebar(event), - _1: { - ctor: '::', - _0: _user$project$Views_EventDetail$eventInstancesSidebar(eventInstances), - _1: {ctor: '[]'} - } + _0: A3(_user$project$Views_EventDetail$eventMetaDataSidebar, event, eventInstances, model), + _1: {ctor: '[]'} } })); }); @@ -16092,9 +16195,9 @@ var _user$project$Views_EventDetail$eventDetailView = F2( return _elm_lang$core$Native_Utils.eq(e.slug, eventSlug); }, model.events)); - var _p9 = event; - if (_p9.ctor === 'Just') { - var _p10 = _p9._0; + var _p13 = event; + if (_p13.ctor === 'Just') { + var _p14 = _p13._0; return A2( _elm_lang$html$Html$div, { @@ -16104,10 +16207,10 @@ var _user$project$Views_EventDetail$eventDetailView = F2( }, { ctor: '::', - _0: _user$project$Views_EventDetail$eventDetailContent(_p10), + _0: _user$project$Views_EventDetail$eventDetailContent(_p14), _1: { ctor: '::', - _0: A2(_user$project$Views_EventDetail$eventDetailSidebar, _p10, model), + _0: A2(_user$project$Views_EventDetail$eventDetailSidebar, _p14, model), _1: {ctor: '[]'} } }); @@ -16224,117 +16327,90 @@ var _user$project$Views_SpeakerDetail$speakerDetailView = F2( return _elm_lang$core$Native_Utils.eq(speaker.slug, speakerSlug); }, model.speakers)); - var image = function () { - var _p1 = speaker; - if (_p1.ctor === 'Just') { - var _p2 = _p1._0.smallPictureUrl; - if (_p2.ctor === 'Just') { - return { - ctor: '::', - _0: A2( - _elm_lang$html$Html$img, - { - ctor: '::', - _0: _elm_lang$html$Html_Attributes$src(_p2._0), - _1: {ctor: '[]'} - }, - {ctor: '[]'}), - _1: {ctor: '[]'} - }; - } else { - return {ctor: '[]'}; - } - } else { - return {ctor: '[]'}; - } - }(); - var _p3 = speaker; - if (_p3.ctor === 'Just') { - var _p4 = _p3._0; + var _p1 = speaker; + if (_p1.ctor === 'Just') { + var _p2 = _p1._0; return A2( _elm_lang$html$Html$div, {ctor: '[]'}, - A2( - _elm_lang$core$Basics_ops['++'], - { - ctor: '::', - _0: A2( - _elm_lang$html$Html$a, - { + { + ctor: '::', + _0: A2( + _elm_lang$html$Html$a, + { + ctor: '::', + _0: _elm_lang$html$Html_Events$onClick(_user$project$Messages$BackInHistory), + _1: { ctor: '::', - _0: _elm_lang$html$Html_Events$onClick(_user$project$Messages$BackInHistory), - _1: { + _0: _elm_lang$html$Html_Attributes$classList( + { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'btn', _1: true}, + _1: { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'btn-default', _1: true}, + _1: {ctor: '[]'} + } + }), + _1: {ctor: '[]'} + } + }, + { + ctor: '::', + _0: A2( + _elm_lang$html$Html$i, + { ctor: '::', _0: _elm_lang$html$Html_Attributes$classList( { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'btn', _1: true}, + _0: {ctor: '_Tuple2', _0: 'fa', _1: true}, _1: { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'btn-default', _1: true}, + _0: {ctor: '_Tuple2', _0: 'fa-chevron-left', _1: true}, _1: {ctor: '[]'} } }), _1: {ctor: '[]'} - } - }, + }, + {ctor: '[]'}), + _1: { + ctor: '::', + _0: _elm_lang$html$Html$text(' Back'), + _1: {ctor: '[]'} + } + }), + _1: { + ctor: '::', + _0: A2( + _elm_lang$html$Html$h3, + {ctor: '[]'}, { ctor: '::', - _0: A2( - _elm_lang$html$Html$i, - { - ctor: '::', - _0: _elm_lang$html$Html_Attributes$classList( - { - ctor: '::', - _0: {ctor: '_Tuple2', _0: 'fa', _1: true}, - _1: { - ctor: '::', - _0: {ctor: '_Tuple2', _0: 'fa-chevron-left', _1: true}, - _1: {ctor: '[]'} - } - }), - _1: {ctor: '[]'} - }, - {ctor: '[]'}), - _1: { - ctor: '::', - _0: _elm_lang$html$Html$text(' Back'), - _1: {ctor: '[]'} - } + _0: _elm_lang$html$Html$text(_p2.name), + _1: {ctor: '[]'} }), _1: { ctor: '::', _0: A2( - _elm_lang$html$Html$h3, + _elm_lang$html$Html$div, {ctor: '[]'}, { ctor: '::', - _0: _elm_lang$html$Html$text(_p4.name), + _0: A2( + _evancz$elm_markdown$Markdown$toHtml, + {ctor: '[]'}, + _p2.biography), _1: {ctor: '[]'} }), _1: { ctor: '::', - _0: A2( - _elm_lang$html$Html$div, - {ctor: '[]'}, - { - ctor: '::', - _0: A2( - _evancz$elm_markdown$Markdown$toHtml, - {ctor: '[]'}, - _p4.biography), - _1: {ctor: '[]'} - }), - _1: { - ctor: '::', - _0: A2(_user$project$Views_SpeakerDetail$speakerEvents, _p4, model), - _1: {ctor: '[]'} - } + _0: A2(_user$project$Views_SpeakerDetail$speakerEvents, _p2, model), + _1: {ctor: '[]'} } } - }, - image)); + } + }); } else { return A2( _elm_lang$html$Html$div, @@ -16368,7 +16444,11 @@ var _user$project$Views_ScheduleOverview$dayEventInstanceIcons = function (event _1: { ctor: '::', _0: {ctor: '_Tuple2', _0: 'pull-right', _1: true}, - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'fa-fw', _1: true}, + _1: {ctor: '[]'} + } } } }), @@ -16390,11 +16470,15 @@ var _user$project$Views_ScheduleOverview$dayEventInstanceIcons = function (event _0: {ctor: '_Tuple2', _0: 'fa', _1: true}, _1: { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'fa-video-camera', _1: true}, + _0: {ctor: '_Tuple2', _0: 'fa-video', _1: true}, _1: { ctor: '::', _0: {ctor: '_Tuple2', _0: 'pull-right', _1: true}, - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'fa-fw', _1: true}, + _1: {ctor: '[]'} + } } } }), @@ -16416,11 +16500,15 @@ var _user$project$Views_ScheduleOverview$dayEventInstanceIcons = function (event _0: {ctor: '_Tuple2', _0: 'fa', _1: true}, _1: { ctor: '::', - _0: {ctor: '_Tuple2', _0: 'fa-ban', _1: true}, + _0: {ctor: '_Tuple2', _0: 'fa-video-slash', _1: true}, _1: { ctor: '::', _0: {ctor: '_Tuple2', _0: 'pull-right', _1: true}, - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'fa-fw', _1: true}, + _1: {ctor: '[]'} + } } } }), @@ -16455,7 +16543,11 @@ var _user$project$Views_ScheduleOverview$dayEventInstanceIcons = function (event _1: { ctor: '::', _0: {ctor: '_Tuple2', _0: 'pull-right', _1: true}, - _1: {ctor: '[]'} + _1: { + ctor: '::', + _0: {ctor: '_Tuple2', _0: 'fa-fw', _1: true}, + _1: {ctor: '[]'} + } } } }), @@ -16690,10 +16782,11 @@ var _user$project$Main$subscriptions = function (model) { }; var _user$project$Main$init = F2( function (flags, location) { - var emptyFilter = A3( + var emptyFilter = A4( _user$project$Models$Filter, {ctor: '[]'}, {ctor: '[]'}, + {ctor: '[]'}, {ctor: '[]'}); var currentRoute = _user$project$Routing$parseLocation(location); var model = _user$project$Models$Model( @@ -16702,6 +16795,7 @@ var _user$project$Main$init = F2( {ctor: '[]'})( {ctor: '[]'})( {ctor: '[]'})( + {ctor: '[]'})( {ctor: '[]'})(flags)(emptyFilter)(location)(currentRoute)(false); return A2( _elm_lang$core$Platform_Cmd_ops['!'], diff --git a/src/program/templates/bornhack-2016_call_for_speakers.html b/src/program/templates/bornhack-2016_call_for_speakers.html deleted file mode 100644 index 251995fc..00000000 --- a/src/program/templates/bornhack-2016_call_for_speakers.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends 'program_base.html' %} - -{% block title %} -Call for Speakers | {{ block.super }} -{% endblock %} - -{% block program_content %} - -{% if not camp.call_for_speakers_open %} -
- Note! This Call for Speakers is no longer relevant. It is kept here for historic purposes. -
-{% endif %} - -

BornHack 2016: Call for Speakers

- -

BornHack 2016 is a 7 days outdoor technology tent camping festival that will take place from the 27th of August to the 3rd of September 2016 on the island of Bornholm in Denmark. It is first time that BornHack will take place and it is our goal to make BornHack a yearly recurring event with 100 to 350 participants.

- -

We are looking for gifted, entertaining and technically enlightening speakers to host talks, lightning talks and workshops at BornHack.

- -

Please reach out to us on speakers@bornhack.dk with a title, abstract, biography, an optional picture of yourself and whether it is a regular talk, lightning talk, workshop or something entirely different. Please ensure that all information is in English. The submitted information will be published both as a news entry and in the official event program on our website, if the submission is accepted.

- -

We are very open to different topics. We expect that the majority of the presentation at BornHack will be on security, networking, programming, distributed systems, privacy, and how these technologies relate to society.

- -

The ticket shop for BornHack 2016 is already open and available at https://bornhack.dk/shop/ - please make sure you have also read our Code of Conduct.

- -

Regular Talk

- -

Regular talks are 45 minutes of presentation, 10 minutes of questions from the audience followed by 5 minutes of preparation for setting up the next speaker.

- -

Please bring your own laptop with your presentation on; it should have an HDMI socket and we will provide the cable to the projector. We do not guarantee that audio will work, even if your laptop supports that.

- -

We will provide you with a one-day entrance ticket free of charge, but due to our limited funds, you would have to pay for transportation to and from the event yourself. We also encourage you to participate for the entire week, but you would also have to pay for the ticket yourself.

- -

Lightning Talk

- -

Lightning talks are 10 minutes of presentation. A laptop will be connected to the projector at the location of the presentations.

- -

A lightning talk is an excellent opportunity for inexperienced speakers to present a topic that you find interesting.

- -

You MUST buy yourself an entrance ticket to host a lightning talk; we are unable to offer free tickets for everyone that gives a lightning talk.

- -

Workshop

- -

We have two workshop areas that will be able to host workshops for approximately 20 people per room. Workshops can be up to 3 hours per slot and can be extended for daily workshops.

- -

You MUST buy yourself an entrance ticket to host a workshop; we are unable to offer free tickets for everyone that hosts a workshop.

- -

Contact Information

- -

The BornHack speakers team can be contacted via speakers@bornhack.dk - for general information reach out to the info team via info@bornhack.dk

- -

We are also reachable via IRC in #BornHack on irc.baconsvin.org or 6nbtgccn5nbcodn3.onion - both listening for TLS connections on port 6697.

- -

For more information, please have a look at https://bornhack.dk/ or follow us on Twitter at @bornhax.

-{% endblock %} diff --git a/src/program/templates/bornhack-2017_call_for_speakers.html b/src/program/templates/bornhack-2017_call_for_speakers.html deleted file mode 100644 index 80e03280..00000000 --- a/src/program/templates/bornhack-2017_call_for_speakers.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'program_base.html' %} - -{% block title %} -Call for Speakers | {{ block.super }} -{% endblock %} - -{% block program_content %} - -{% if not camp.call_for_speakers_open %} -
- Note! This Call for Speakers is no longer relevant. It is kept here for historic purposes. -
-{% endif %} - -

Call for Speakers

-

We are looking for gifted, talented, humourous, technically enlightened speakers to host talks, lightning talks, and workshops at BornHack.

- -

We are very open to different topics. We expect that the majority of the presentation at BornHack will be on security, networking, programming, distributed systems, privacy, and how these technologies relate to society.

- -

BornHack is trying to be an inclusive event so please make sure you have read and understood our Code of Conduct.

- -

Regular Talk

-

Regular talks are 45 minutes of presentation, 10 minutes of questions from the audience followed by 5 minutes of preparation for setting up the next speaker.

- -

Please bring your own laptop with your presentation on; it should have an ordinary HDMI output and we will provide the cable to the projector. We do not guarantee that audio will work, even if your laptop supports it - please reach out to us early if this is a requirement.

- -

We will provide speakers with a one-day entrance ticket free of charge, but due to our limited funds, you would have to pay for transportation to and from the event yourself. We also encourage speakers to participate for the entire week, but you will have to pay for the full ticket yourself.

- -

Lightning Talk

-

Lightning talks are 10 minutes of presentation. A laptop will be connected to the projector at the location of the presentations.

- -

A lightning talk is an excellent opportunity for inexperienced speakers to share an interesting idea, presentation, or maybe just a small story.

- -

You must buy an entrance ticket to host a lightning talk; we are unable to offer free tickets for lightning talks.

- -

Workshops

-

We have two workshop areas that will be able to host workshops for approximately 20 people per room. Workshops can be up to 3 hours per slot and can be extended to full day workshops.

- -

You must buy an entrance ticket to host a workshop; we are unable to offer free tickets for workshops.

- -

Submitting Content

-

Please submit content for BornHack 2017 as early as possible. You can submit content via our website:

- -
    -
  1. Create a user account on the BornHack website
  2. -
  3. Visit the proposals page
  4. -
  5. Propose a new speaker
  6. -
  7. Propose a new event
  8. -
- -

We will review incoming proposals and notify you as early as possible on whether the proposal was accepted or not. Proposals submitted before 1st of July will be notified by us no later than the 16th of July. Late submissions are welcome, but we might be running low on available slots at that time.

- -

Contact Information

-

The BornHack content team can be reached at content@bornhack.dk - for general questions regarding the event please reach out to the info team at info@bornhack.dk

- -

We are reachable via IRC in #BornHack on irc.baconsvin.org (6nbtgccn5nbcodn3.onion) on port 6697 with TLS, you can also follow us on Twitter at @bornhax.

- -{% endblock %} diff --git a/src/program/templates/bornhack-2019_call_for_speakers.html b/src/program/templates/bornhack-2019_call_for_speakers.html deleted file mode 100644 index 4a5180a5..00000000 --- a/src/program/templates/bornhack-2019_call_for_speakers.html +++ /dev/null @@ -1 +0,0 @@ -program/templates/bornhack-2019_call_for_speakers.html \ No newline at end of file diff --git a/src/program/templates/bornhack-2018_call_for_speakers.html b/src/program/templates/bornhack-2020_call_for_speakers.html similarity index 100% rename from src/program/templates/bornhack-2018_call_for_speakers.html rename to src/program/templates/bornhack-2020_call_for_speakers.html diff --git a/src/program/templates/call_for_participation.html b/src/program/templates/call_for_participation.html new file mode 100644 index 00000000..be660949 --- /dev/null +++ b/src/program/templates/call_for_participation.html @@ -0,0 +1,22 @@ +{% extends 'program_base.html' %} +{% load commonmark %} + +{% block title %} +Call for Participation | {{ block.super }} +{% endblock %} + +{% block program_content %} + +{% if not camp.call_for_participation_open %} +
+ Note! This Call for Particilation is not open. +
+{% endif %} + +{% if not camp.call_for_participation %} +

This CFP has not been written yet.

+{% else %} +{{ camp.call_for_participation|trustedcommonmark }} +{% endif %} + +{% endblock %} diff --git a/src/program/templates/combined_proposal_select_person.html b/src/program/templates/combined_proposal_select_person.html new file mode 100644 index 00000000..a57fd5dd --- /dev/null +++ b/src/program/templates/combined_proposal_select_person.html @@ -0,0 +1,33 @@ +{% extends 'program_base.html' %} + +{% block title %} +Use Existing {{ eventtype.host_title }} or Add New? | {{ block.super }} +{% endblock %} + +{% block program_content %} + +

Use Existing {{ eventtype.host_title }}?

+ +

Pick a {{ eventtype.host_title }} from the list below, or press the button at the bottom to add a new {{ eventtype.host_title }} for this {{ eventtype.name }}.

+ +
+
+

Use an Existing {{ eventtype.host_title }}

+
+
+
+ {% for speakerproposal in speakerproposal_list %} + +

+ Use {{ speakerproposal.name }} as {{ eventtype.host_title }} +

+
+ {% endfor %} +
+
+
+ + Add New {{ eventtype.host_title }} + Cancel +{% endblock %} + diff --git a/src/program/templates/combined_proposal_submit.html b/src/program/templates/combined_proposal_submit.html new file mode 100644 index 00000000..aed9a73d --- /dev/null +++ b/src/program/templates/combined_proposal_submit.html @@ -0,0 +1,16 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +

Submit {{ camp.title }} {{ eventtype.name }}

+ +
+ {% csrf_token %} + {% for field in form %} + {% bootstrap_field field %} + {% endfor %} + {% bootstrap_button "Submit for Review" button_type="submit" button_class="btn-primary" %} +
+ +{% endblock program_content %} + diff --git a/src/program/templates/event_list.html b/src/program/templates/event_list.html index e1d53a74..3389fe50 100644 --- a/src/program/templates/event_list.html +++ b/src/program/templates/event_list.html @@ -20,17 +20,17 @@ {% for event in event_list %} {% if event.event_type.include_in_event_list %} - - - {{ event.event_type.name }} + + + {{ event.event_type.name }} - {{ event.title }} + {{ event.title }} {% for speaker in event.speakers.all %} - {{ speaker.name }}
+ {{ speaker.name }}
{% empty %} N/A {% endfor %} diff --git a/src/program/templates/event_proposal_add_person.html b/src/program/templates/event_proposal_add_person.html new file mode 100644 index 00000000..ee8a65a5 --- /dev/null +++ b/src/program/templates/event_proposal_add_person.html @@ -0,0 +1,15 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +

Add {{ eventproposal.event_type.host_title }} {{ speakerproposal.name }} to {{ eventproposal.title }}

+ +

Really add {{ speakerproposal.name }} as {{ eventproposal.event_type.host_title }} for {{ eventproposal.title }}? +

+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button " Yes" button_type="submit" button_class="btn-success" %} + Cancel +
+{% endblock program_content %} + diff --git a/src/program/templates/event_proposal_remove_person.html b/src/program/templates/event_proposal_remove_person.html new file mode 100644 index 00000000..dd1ebcae --- /dev/null +++ b/src/program/templates/event_proposal_remove_person.html @@ -0,0 +1,15 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +

Remove "{{ speakerproposal.name }}" from "{{ eventproposal.title }}"?

+

Really remove this {{ eventproposal.event_type.host_title }} from this event?

+ +
+ {% csrf_token %} + {% bootstrap_button " Remove" button_type="submit" button_class="btn-danger" %} + Cancel +
+ +{% endblock program_content %} + diff --git a/src/program/templates/event_proposal_select_person.html b/src/program/templates/event_proposal_select_person.html new file mode 100644 index 00000000..3c77bde4 --- /dev/null +++ b/src/program/templates/event_proposal_select_person.html @@ -0,0 +1,33 @@ +{% extends 'program_base.html' %} + +{% block title %} +Add {{ eventproposal.event_type.host_title }} to {{ eventproposal.title }} | {{ block.super }} +{% endblock %} + +{% block program_content %} + +

Add New {{ eventproposal.event_type.host_title }} to {{ eventproposal.title }}

+ +

You are adding a new {{ eventproposal.event_type.host_title }} to {{ eventproposal.title }}. Either pick an existing {{ eventproposal.event_type.host_title }} from the list below, or press the button to create a new {{ eventproposal.event_type.host_title }}.

+ +
+
+

Existing Artists

+
+
+
+ {% for speakerproposal in speakerproposal_list %} + +

+ Add {{ speakerproposal.name }} to {{ eventproposal.title }} +

+
+ {% endfor %} +
+
+
+ + Add New {{ eventproposal.event_type.host_title }} + Cancel +{% endblock %} + diff --git a/src/program/templates/event_proposal_type_select.html b/src/program/templates/event_proposal_type_select.html new file mode 100644 index 00000000..40e730e0 --- /dev/null +++ b/src/program/templates/event_proposal_type_select.html @@ -0,0 +1,10 @@ +{% extends 'program_base.html' %} + +{% block title %} +Select Event Type | {{ block.super }} +{% endblock %} + +{% block program_content %} +{% include 'includes/event_proposal_type_select.html' %} +{% endblock %} + diff --git a/src/program/templates/event_type_select.html b/src/program/templates/event_type_select.html new file mode 100644 index 00000000..40e730e0 --- /dev/null +++ b/src/program/templates/event_type_select.html @@ -0,0 +1,10 @@ +{% extends 'program_base.html' %} + +{% block title %} +Select Event Type | {{ block.super }} +{% endblock %} + +{% block program_content %} +{% include 'includes/event_proposal_type_select.html' %} +{% endblock %} + diff --git a/src/program/templates/eventproposal_detail.html b/src/program/templates/eventproposal_detail.html index d1988d18..d24c5e9e 100644 --- a/src/program/templates/eventproposal_detail.html +++ b/src/program/templates/eventproposal_detail.html @@ -1,24 +1,22 @@ {% extends 'program_base.html' %} -{% load commonmark %} {% block program_content %} -

{{ camp.title }} Event Proposal Details

- -
    -
  • Status: {{ eventproposal.proposal_status }}
  • -
  • ID: {{ eventproposal.uuid }}
  • -
- -
-
{{ eventproposal.title }}
-
- {{ eventproposal.abstract|commonmark }} -
+{% if not camp.call_for_participation_open %} +
+ Note! This Call for Particilation is not open.
+{% endif %} + +

Details for {{ eventproposal.title }}

+ +{% include 'includes/eventproposal_detail.html' %}

- Back to List + Back to List + {% if camp.call_for_participation_open and not camp.read_only %} + Delete + {% endif %}

{% endblock program_content %} diff --git a/src/program/templates/eventproposal_form.html b/src/program/templates/eventproposal_form.html index 26a36643..c806a7ba 100644 --- a/src/program/templates/eventproposal_form.html +++ b/src/program/templates/eventproposal_form.html @@ -2,12 +2,15 @@ {% load bootstrap3 %} {% block program_content %} -

{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Event Proposal

+{% if speaker %} +

Submit new {{ event_type.name }} by {{ speaker.name }}

+{% else %} +

{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} {{ event_type.name }}

+{% endif %}
{% csrf_token %} {% bootstrap_form form %} - {% bootstrap_button "Save draft" button_type="submit" button_class="btn-primary" %} + {% bootstrap_button "Submit for Review" button_type="submit" button_class="btn-primary" %}
- {% endblock program_content %} diff --git a/src/program/templates/eventproposal_submit.html b/src/program/templates/eventproposal_submit.html deleted file mode 100644 index 25d4d6ea..00000000 --- a/src/program/templates/eventproposal_submit.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'program_base.html' %} -{% load bootstrap3 %} - -{% block program_content %} -

Confirm Submission

-
- {% csrf_token %} - {% bootstrap_form form %} -

Really submit this event proposal for approval?

- {% bootstrap_button "Submit" button_type="submit" button_class="btn-primary" %} -
- -{% endblock program_content %} - diff --git a/src/program/templates/includes/event_proposal_table.html b/src/program/templates/includes/event_proposal_table.html new file mode 100644 index 00000000..531423fa --- /dev/null +++ b/src/program/templates/includes/event_proposal_table.html @@ -0,0 +1,43 @@ + + + + + + + + + + {% if request.resolver_match.app_name == "program" %} + + {% endif %} + + + + {% for eventproposal in eventproposals %} + + + + + + + + {% if request.resolver_match.app_name == "program" %} + + {% endif %} + + {% endfor %} + +
TitleTypeURLsPeopleTrackStatusAvailable Actions
{{ eventproposal.title }} {{ eventproposal.event_type }}{% for url in eventproposal.urls.all %} + {% empty %}N/A{% endfor %} + {% for person in eventproposal.speakers.all %} + {% if request.resolver_match.app_name == "program" %} + + {% else %} + + {% endif %} + {% endfor %} + {{ eventproposal.track.name }}{{ eventproposal.proposal_status }} + + Details + +
diff --git a/src/program/templates/includes/event_proposal_type_select.html b/src/program/templates/includes/event_proposal_type_select.html new file mode 100644 index 00000000..0a5d3d9d --- /dev/null +++ b/src/program/templates/includes/event_proposal_type_select.html @@ -0,0 +1,30 @@ +
+
+

Submit New Proposal{% if speaker %} for {{ speaker.name }}{% endif %}

+
+
+

What would {% if speaker %}{{ speaker.name }}{% else %}you{% endif %} like to host?

+ {% if speaker %} +

You are submitting a new proposal for {{ speaker.name }}. Please begin by selecting the type of proposal below:

+ {% else %} +

To submit content for {{ camp.title }} please begin by selecting the type of event below:

+ {% endif %} + +

If you have questions or experience problems submitting proposals here please let us know on IRC or by mail. You can also send an email with your proposal and the Content team will take care of creating it in the system.

+
+
+ diff --git a/src/program/templates/includes/eventproposal_detail.html b/src/program/templates/includes/eventproposal_detail.html new file mode 100644 index 00000000..9aeb715e --- /dev/null +++ b/src/program/templates/includes/eventproposal_detail.html @@ -0,0 +1,61 @@ +{% load commonmark %} + +
+
+ Title: {{ eventproposal.title }}
+ ID: {{ eventproposal.uuid }}
+ Status: {{ eventproposal.proposal_status }}
+ Duration: {{ eventproposal.duration|default:"Not defined" }}
+
+
+ + +
+
Abstract
+
+ {{ eventproposal.abstract|untrustedcommonmark }} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + Modify + {% endif %} +
+
+ +
+
Notes
+
+ {{ eventproposal.submission_notes|untrustedcommonmark }} +
+
+ +
+
URLs
+
+ {% if eventproposal.urls.exists %} + {% include 'includes/eventproposalurl_table.html' %} + {% else %} + Nothing found. + {% endif %} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + Add URL + {% endif %} +
+
+ +
+
{{ eventproposal.event_type.host_title }} List
+
+ {% if eventproposal.speakers.exists %} + {% include 'includes/speaker_proposal_table.html' with speakerproposals=eventproposal.speakers.all %} + {% else %} + Nothing found. + {% endif %} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + {% if eventproposal.get_available_speakerproposals.exists %} + Add {{ eventproposal.event_type.host_title }} + {% else %} + Add {{ eventproposal.event_type.host_title }} + {% endif %} + {% endif %} +
+
+ diff --git a/src/program/templates/includes/eventproposalurl_table.html b/src/program/templates/includes/eventproposalurl_table.html new file mode 100644 index 00000000..0208a8ad --- /dev/null +++ b/src/program/templates/includes/eventproposalurl_table.html @@ -0,0 +1,25 @@ + + + + + + {% if not camp.read_only and request.resolver_match.app_name == "program" and eventproposal.user == request.user %} + + {% endif %} + + + + {% for url in eventproposal.urls.all %} + + + + + + {% endfor %} + +
TypeURLAvailable Actions
{{ url.urltype.name }}{{ url }} + {% if not camp.read_only and request.resolver_match.app_name == "program" and eventproposal.user == request.user %} + Update + Delete + {% endif %} +
diff --git a/src/program/templates/includes/program_menu.html b/src/program/templates/includes/program_menu.html new file mode 100644 index 00000000..d5439a8b --- /dev/null +++ b/src/program/templates/includes/program_menu.html @@ -0,0 +1,12 @@ + Schedule + Events + Speakers + Call for Participation + {% if request.user.is_authenticated %} + {% if camp.call_for_participation_open %} + Submit Proposal + {% else %} + View Proposals + {% endif %} + {% endif %} + diff --git a/src/program/templates/includes/speaker_proposal_table.html b/src/program/templates/includes/speaker_proposal_table.html new file mode 100644 index 00000000..4c6cbbb4 --- /dev/null +++ b/src/program/templates/includes/speaker_proposal_table.html @@ -0,0 +1,47 @@ + + + + + + + + {% if request.resolver_match.app_name == "program" %} + + {% endif %} + + + + {% for speakerproposal in speakerproposals %} + + + + + + {% if request.resolver_match.app_name == "program" %} + + {% endif %} + + {% endfor %} + +
NameEventsURLsStatusAvailable Actions
{{ speakerproposal.name }} + {% if speakerproposal.eventproposals.all %} + {% for ep in speakerproposal.eventproposals.all %} + + {% endfor %} + {% else %} + N/A + {% endif %} + + {% for url in speakerproposal.urls.all %} + + {% empty %} + N/A + {% endfor %} + {{ speakerproposal.proposal_status }} + + Details + + {% if camp.call_for_participation_open and not camp.read_only and eventproposal and eventproposal.speakers.count > 1 %} + Remove {{ eventproposal.event_type.host_title }} + {% endif %} +
diff --git a/src/program/templates/includes/speakerproposal_detail.html b/src/program/templates/includes/speakerproposal_detail.html new file mode 100644 index 00000000..f3eba71e --- /dev/null +++ b/src/program/templates/includes/speakerproposal_detail.html @@ -0,0 +1,40 @@ +{% load commonmark %} +
+
{{ speakerproposal.name }}
+
+ {{ speakerproposal.biography|untrustedcommonmark }} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + Modify + {% endif %} +
+ +
+ +
+
URLs for {{ speakerproposal.name }}
+
+ {% if speakerproposal.urls.exists %} + {% include 'includes/speakerproposalurl_table.html' %} + {% else %} + Nothing found. + {% endif %} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + Add URL + {% endif %} +
+
+ +
+
Events for {{ speakerproposal.name }}
+
+ {% if speakerproposal.eventproposals.exists %} + {% include 'includes/event_proposal_table.html' with eventproposals=speakerproposal.eventproposals.all %} + {% else %} + Nothing found. + {% endif %} + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + Add New Event + {% endif %} +
+
+ diff --git a/src/program/templates/includes/speakerproposalurl_table.html b/src/program/templates/includes/speakerproposalurl_table.html new file mode 100644 index 00000000..db01bae9 --- /dev/null +++ b/src/program/templates/includes/speakerproposalurl_table.html @@ -0,0 +1,25 @@ + + + + + + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + + {% endif %} + + + + {% for url in speakerproposal.urls.all %} + + + + {% if camp.call_for_participation_open and not camp.read_only and request.resolver_match.app_name == "program" %} + + {% endif %} + + {% endfor %} + +
TypeURLsAvailable Actions
{{ url.urltype.name }}{{ url }} + Update + Delete +
diff --git a/src/program/templates/noscript_schedule_view.html b/src/program/templates/noscript_schedule_view.html index c1dfdda5..af36423b 100644 --- a/src/program/templates/noscript_schedule_view.html +++ b/src/program/templates/noscript_schedule_view.html @@ -26,7 +26,7 @@ {{ instance.when.lower|date:"H:i" }}-{{ instance.when.upper|date:"H:i" }} - {{ instance.event.title }} + {{ instance.event.title }} {{ instance.location.name }} {% endfor %} diff --git a/src/program/templates/program_base.html b/src/program/templates/program_base.html index 64887332..13d6d3e2 100644 --- a/src/program/templates/program_base.html +++ b/src/program/templates/program_base.html @@ -6,20 +6,10 @@
- Schedule - Events - Speakers - {% if request.user.is_authenticated %} - Your Proposals - {% endif %} + {% include 'includes/program_menu.html' %}

diff --git a/src/program/templates/proposal_delete.html b/src/program/templates/proposal_delete.html new file mode 100644 index 00000000..89842635 --- /dev/null +++ b/src/program/templates/proposal_delete.html @@ -0,0 +1,19 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +{% if object.name %} +

Delete "{{ object.name }}"

+{% else %} +

Delete "{{ object.title }}"

+{% endif %} +

Really delete this proposal? This action cannot be undone.

+ +
+ {% csrf_token %} + {% bootstrap_button " Delete" button_type="submit" button_class="btn-danger" %} + Cancel +
+ +{% endblock program_content %} + diff --git a/src/program/templates/proposal_list.html b/src/program/templates/proposal_list.html index 35221261..13245d8f 100644 --- a/src/program/templates/proposal_list.html +++ b/src/program/templates/proposal_list.html @@ -5,98 +5,39 @@ Proposals | {{ block.super }} {% endblock %} {% block program_content %} -

Submitting

-

To submit a talk or other event for {{ camp.title }} you need to to the following:

-
    -
  1. First you propose one or more speakers. Most events just have one speaker, but some events might have two or more. Be sure to create everyone before going on to step 2.
  2. -
  3. Then you propose one or more events. The Propose New Event form will allow you to choose the speaker(s) you proposed.
  4. -
- -

If you experience problems submitting proposals here please let us know on IRC or by mail. You can also send an email with your proposal and the Content team will take care of creating it in the system.

- -

Your {{ camp.title }} Speaker Proposals

-{% if speakerproposal_list %} - - - - - - - - - - {% for speakerproposal in speakerproposal_list %} - - - - - - {% endfor %} - -
NameStatusActions
{{ speakerproposal.name }}{{ speakerproposal.proposal_status }} - Details - {% if not camp.read_only %} - Modify - {% if speakerproposal.proposal_status == "pending" or speakerproposal.proposal_status == "approved" %} - Submit - {% else %} - Submit - {% endif %} - Delete - {% endif %} -
+{% if camp.call_for_participation_open %} + {% include 'includes/event_proposal_type_select.html' %} {% else %} -

No speaker proposals found

+
+ Note! This Call for Particilation is not open. +
{% endif %} -{% if not camp.read_only and camp.call_for_speakers_open %} -Propose New Speaker -{% endif %} -

-
-

+{% if speakerproposal_list or eventproposal_list %} +
+
+

Existing Proposals

+
+
+

People

+ {% if speakerproposal_list %} + {% include 'includes/speaker_proposal_table.html' with speakerproposals=speakerproposal_list %} + {% else %} + Nothing found. + {% endif %} -

Your {{ camp.title }} Event Proposals

-{% if eventproposal_list %} - - - - - - - - - - - {% for eventproposal in eventproposal_list %} - - - - - - - {% endfor %} - -
TitleTypeStatusActions
{{ eventproposal.title }}{{ eventproposal.event_type }}{{ eventproposal.proposal_status }} - Details - {% if not camp.read_only %} - Modify - {% if eventproposal.proposal_status == "pending" %} - Submit - {% else %} - Submit - {% endif %} - Delete - {% endif %} -
-{% else %} -

No event proposals found

-{% endif %} +


-{% if not camp.read_only and camp.call_for_speakers_open %} - Propose New Event +

Events

+ {% if eventproposal_list %} + {% include 'includes/event_proposal_table.html' with eventproposals=eventproposal_list %} + {% else %} + Nothing found. + {% endif %} +
+
{% endif %} {% endblock %} diff --git a/src/program/templates/schedule_base.html b/src/program/templates/schedule_base.html index a7b3ebd7..063af2ce 100644 --- a/src/program/templates/schedule_base.html +++ b/src/program/templates/schedule_base.html @@ -31,8 +31,8 @@
- ICS + ICS
@@ -88,7 +88,7 @@
-{% url 'schedule_index' camp_slug=camp.slug as baseurl %} +{% url 'program:schedule_index' camp_slug=camp.slug as baseurl %} diff --git a/src/program/templates/speaker_detail.html b/src/program/templates/speaker_detail.html index 1c53a444..387a814a 100644 --- a/src/program/templates/speaker_detail.html +++ b/src/program/templates/speaker_detail.html @@ -5,19 +5,21 @@

{{ speaker.name }}

-{% if speaker.picture_large and speaker.picture_small %}
-
-{{ speaker.biography|commonmark }} -
-
- - {{ camp.title }} speaker picture of {{ speaker.name }} - +
+{{ speaker.biography|untrustedcommonmark }}
+ +
+ +

URLs for {{ speaker.name }}

+{% if speaker.urls.exists %} + {% for url in speaker.urls.all %} +

{{ url.urltype }}: {{ url.url }}

+ {% endfor %} {% else %} -{{ speaker.biography|commonmark }} +

No URLs found.

{% endif %}
@@ -28,11 +30,11 @@ {{ event.event_type.name }} - {{ event.title }} + {{ event.title }} - {{ event.abstract|commonmark }} + {{ event.abstract|untrustedcommonmark }} -

Instances

+

Scheduled Instances of "{{ event.title }}"

    {% for ei in event.instances.all %}
  • {{ ei.when.lower|date:"l M. d H:i" }} - {{ ei.when.upper|date:"H:i" }}
  • @@ -40,7 +42,6 @@ No instances scheduled yet {% endfor %}
-
{% empty %} No events registered for this speaker yet diff --git a/src/program/templates/speaker_list.html b/src/program/templates/speaker_list.html index 72f53dec..bfdad26b 100644 --- a/src/program/templates/speaker_list.html +++ b/src/program/templates/speaker_list.html @@ -13,12 +13,11 @@ +{% else %} +

No speakers found for {{ camp.title }}

{% endif %} - -

Call for Speakers

{% endblock program_content %} diff --git a/src/program/templates/speakerproposal_delete.html b/src/program/templates/speakerproposal_delete.html new file mode 100644 index 00000000..de47ab2d --- /dev/null +++ b/src/program/templates/speakerproposal_delete.html @@ -0,0 +1,15 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +

Delete {{ object.name }}

+

Really delete this proposal? This action cannot be undone.

+ +
+ {% csrf_token %} + {% bootstrap_button "Delete" button_type="submit" button_class="btn-danger" %} + {% bootstrap_button "Cancel" button_type="link" button_class="btn-primary" %} +
+ +{% endblock program_content %} + diff --git a/src/program/templates/speakerproposal_detail.html b/src/program/templates/speakerproposal_detail.html index cf1ed7fb..451909e4 100644 --- a/src/program/templates/speakerproposal_detail.html +++ b/src/program/templates/speakerproposal_detail.html @@ -1,37 +1,24 @@ {% extends 'program_base.html' %} -{% load commonmark %} {% block program_content %} -

{{ camp.title }} Speaker Proposal Details

- -
    -
  • Status: {{ speakerproposal.proposal_status }}
  • -
  • ID: {{ speakerproposal.uuid }}
  • -
- -
-
{{ speakerproposal.name }}
-
- {% if speakerproposal.picture_large and speakerproposal.picture_small %} -
-
- {{ speakerproposal.biography|commonmark }} -
-
- - {{ camp.title }} speaker picture of {{ speakerproposal.name }} - -
-
- {% else %} - {{ speakerproposal.biography|commonmark }} - {% endif %} -
+{% if not camp.call_for_participation_open %} +
+ Note! This Call for Particilation is not open.
+{% endif %} + +

Details for {{ speakerproposal.name }}

+ +{% include 'includes/speakerproposal_detail.html' %}

- Back to List + Back to List + {% if camp.call_for_participation_open and not camp.read_only %} + {% if not speakerproposal.eventproposals.all %} + Delete Person + {% endif %} + {% endif %}

{% endblock program_content %} diff --git a/src/program/templates/speakerproposal_form.html b/src/program/templates/speakerproposal_form.html index 87723e7a..7da0bd55 100644 --- a/src/program/templates/speakerproposal_form.html +++ b/src/program/templates/speakerproposal_form.html @@ -2,11 +2,19 @@ {% load bootstrap3 %} {% block program_content %} -

{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Speaker Proposal

+ +

+ {% if object %} + Update {{ object.name }} Details + {% else %} + Add {{ eventproposal.event_type.host_title }} to {{ eventproposal.title }} + {% endif %} +

+
{% csrf_token %} {% bootstrap_form form %} - {% bootstrap_button "Save draft" button_type="submit" button_class="btn-primary" %} + {% bootstrap_button "Submit for review" button_type="submit" button_class="btn-primary" %}
{% endblock program_content %} diff --git a/src/program/templates/url_delete.html b/src/program/templates/url_delete.html new file mode 100644 index 00000000..5ebb0b51 --- /dev/null +++ b/src/program/templates/url_delete.html @@ -0,0 +1,21 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} +

Delete URL?

+

Really delete this URL?

+

{{ url.urltype }}: {{ url.url }}

+

This action cannot be undone.

+ +
+ {% csrf_token %} + {% bootstrap_button " Delete" button_type="submit" button_class="btn-danger" %} + {% if speakerproposal %} + + {% else %} + + {% endif %} + Cancel +
+{% endblock program_content %} + diff --git a/src/program/templates/url_form.html b/src/program/templates/url_form.html new file mode 100644 index 00000000..343a77b7 --- /dev/null +++ b/src/program/templates/url_form.html @@ -0,0 +1,21 @@ +{% extends 'program_base.html' %} +{% load bootstrap3 %} + +{% block program_content %} + +

+ {% if object %} + Update URL + {% else %} + Add URL to {% if speakerproposal %}{{ speakerproposal.name }}{% else %}{{ eventproposal.title }}{% endif %} + {% endif %} +

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button "Save URL" button_type="submit" button_class="btn-primary" %} +
+ +{% endblock program_content %} + diff --git a/src/program/tests.py b/src/program/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/src/program/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/src/program/urls.py b/src/program/urls.py new file mode 100644 index 00000000..ff5cdc6d --- /dev/null +++ b/src/program/urls.py @@ -0,0 +1,191 @@ +from django.urls import path, include +from .views import * + +app_name = 'program' + +urlpatterns = [ + path( + '', + ScheduleView.as_view(), + name='schedule_index' + ), + path( + 'noscript/', + NoScriptScheduleView.as_view(), + name='noscript_schedule_index' + ), + path( + 'ics/', ICSView.as_view(), name="ics_view" + ), + path( + 'control/', ProgramControlCenter.as_view(), name="program_control_center" + ), + path( + 'proposals/', include([ + path( + '', + ProposalListView.as_view(), + name='proposal_list', + ), + path( + 'submit/', include([ + path( + '', + CombinedProposalTypeSelectView.as_view(), + name='proposal_combined_type_select', + ), + path( + '/', + CombinedProposalSubmitView.as_view(), + name='proposal_combined_submit', + ), + path( + '/select_person/', + CombinedProposalPersonSelectView.as_view(), + name='proposal_combined_person_select', + ), + ]), + ), + path( + 'people/', include([ + path( + '/', + SpeakerProposalDetailView.as_view(), + name='speakerproposal_detail' + ), + path( + '/update/', + SpeakerProposalUpdateView.as_view(), + name='speakerproposal_update' + ), + path( + '/delete/', + SpeakerProposalDeleteView.as_view(), + name='speakerproposal_delete' + ), + path( + '/add_event/', + EventProposalTypeSelectView.as_view(), + name='eventproposal_typeselect' + ), + path( + '/add_event//', + EventProposalCreateView.as_view(), + name='eventproposal_create' + ), + path( + '/add_url/', + UrlCreateView.as_view(), + name='speakerproposalurl_create' + ), + path( + '/urls//update/', + UrlUpdateView.as_view(), + name='speakerproposalurl_update' + ), + path( + '/urls//delete/', + UrlDeleteView.as_view(), + name='speakerproposalurl_delete' + ), + ]) + ), + path( + 'events/', include([ + path( + '/', + EventProposalDetailView.as_view(), + name='eventproposal_detail' + ), + path( + '/update/', + EventProposalUpdateView.as_view(), + name='eventproposal_update' + ), + path( + '/delete/', + EventProposalDeleteView.as_view(), + name='eventproposal_delete' + ), + path( + '/add_person/', + EventProposalSelectPersonView.as_view(), + name='eventproposal_selectperson' + ), + path( + '/add_person/new/', + SpeakerProposalCreateView.as_view(), + name='speakerproposal_create' + ), + path( + '/add_person//', + EventProposalAddPersonView.as_view(), + name='eventproposal_addperson' + ), + path( + '/remove_person//', + EventProposalRemovePersonView.as_view(), + name='eventproposal_removeperson' + ), + path( + '/add_url/', + UrlCreateView.as_view(), + name='eventproposalurl_create' + ), + path( + '/urls//update/', + UrlUpdateView.as_view(), + name='eventproposalurl_update' + ), + path( + '/urls//delete/', + UrlDeleteView.as_view(), + name='eventproposalurl_delete' + ), + ]) + ), + ]) + ), + path( + 'speakers/', include([ + path( + '', + SpeakerListView.as_view(), + name='speaker_index' + ), + path( + '/', + SpeakerDetailView.as_view(), + name='speaker_detail' + ), + ]), + ), + path( + 'events/', + EventListView.as_view(), + name='event_index' + ), + # legacy CFS url kept on purpose to keep old links functional + path( + 'call-for-speakers/', + CallForParticipationView.as_view(), + name='call_for_speakers' + ), + path( + 'call-for-participation/', + CallForParticipationView.as_view(), + name='call_for_participation' + ), + path( + 'calendar', + ICSView.as_view(), + name='ics_calendar' + ), + # this must be the last URL here or the regex will overrule the others + path( + '/', + EventDetailView.as_view(), + name='event_detail' + ), +] + diff --git a/src/program/views.py b/src/program/views.py index acb08112..af900b29 100644 --- a/src/program/views.py +++ b/src/program/views.py @@ -1,45 +1,51 @@ -import datetime import logging -import os +from collections import OrderedDict from django.views.generic import ListView, TemplateView, DetailView, View -from django.views.generic.edit import CreateView, UpdateView +from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.conf import settings -from django.views.decorators.http import require_safe from django.http import Http404, HttpResponse from django.utils.decorators import method_decorator from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib import messages -from django.urls import reverse -from django.db.models import Q +from django.urls import reverse, reverse_lazy from django.template import Engine, Context - +from django.shortcuts import redirect +from django.shortcuts import get_object_or_404 +from betterforms.multiform import MultiModelForm import icalendar from camps.mixins import CampViewMixin +from program.models import Url, UrlType from .mixins import ( - CreateProposalMixin, - EnsureUnapprovedProposalMixin, EnsureUserOwnsProposalMixin, EnsureWritableCampMixin, - PictureViewMixin, - EnsureCFSOpenMixin + EnsureCFPOpenMixin, + UrlViewMixin, ) from .email import ( + add_new_eventproposal_email, + add_new_speakerproposal_email, add_speakerproposal_updated_email, add_eventproposal_updated_email ) from . import models +from .forms import SpeakerProposalForm, EventProposalForm + + logger = logging.getLogger("bornhack.%s" % __name__) -############## ical calendar ######################################################## +################################################################################################### +# ical calendar class ICSView(CampViewMixin, View): def get(self, request, *args, **kwargs): - eventinstances = models.EventInstance.objects.filter(event__camp=self.camp) + eventinstances = models.EventInstance.objects.filter( + event__track__camp=self.camp + ) # Type query type_query = request.GET.get('type', None) @@ -90,7 +96,8 @@ class ICSView(CampViewMixin, View): return response -############## proposals ######################################################## +################################################################################################### +# proposals list view class ProposalListView(LoginRequiredMixin, CampViewMixin, ListView): @@ -105,55 +112,139 @@ class ProposalListView(LoginRequiredMixin, CampViewMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # also add eventproposals to the context - context['eventproposal_list'] = models.EventProposal.objects.filter(camp=self.camp, user=self.request.user) + context['eventproposal_list'] = models.EventProposal.objects.filter(track__camp=self.camp, user=self.request.user) + context['eventtype_list'] = models.EventType.objects.filter(public=True) return context -class SpeakerProposalCreateView(LoginRequiredMixin, CampViewMixin, CreateProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, CreateView): +################################################################################################### +# speakerproposal views + + +class SpeakerProposalCreateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, CreateView): + """ This view allows a user to create a new SpeakerProposal linked to an existing EventProposal """ model = models.SpeakerProposal - fields = ['name', 'biography', 'picture_small', 'picture_large', 'submission_notes'] template_name = 'speakerproposal_form.html' + form_class = SpeakerProposalForm + + def dispatch(self, request, *args, **kwargs): + """ Get the eventproposal object """ + self.eventproposal = get_object_or_404(models.EventProposal, pk=kwargs['event_uuid']) + return super().dispatch(request, *args, **kwargs) def get_success_url(self): - return reverse('proposal_list', kwargs={'camp_slug': self.camp.slug}) + return reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) + def get_form_kwargs(self): + """ + Set camp and eventtype for the form + """ + kwargs = super().get_form_kwargs() + kwargs.update({ + 'camp': self.camp, + 'eventtype': self.eventproposal.event_type + }) + return kwargs -class SpeakerProposalUpdateView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, UpdateView): - model = models.SpeakerProposal - fields = ['name', 'biography', 'picture_small', 'picture_large', 'submission_notes'] - template_name = 'speakerproposal_form.html' - - def get_success_url(self): - return reverse('proposal_list', kwargs={'camp_slug': self.camp.slug}) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['eventproposal'] = self.eventproposal + return context def form_valid(self, form): - if form.instance.proposal_status == models.UserSubmittedModel.PROPOSAL_PENDING: - messages.warning(self.request, "Your speaker proposal has been reverted to status draft. Please submit it again when you are ready.") - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_DRAFT + # set user before saving + form.instance.user = self.request.user + form.instance.camp = self.camp - if form.instance.proposal_status == models.UserSubmittedModel.PROPOSAL_APPROVED: - messages.warning(self.request, "Your speaker proposal has been set to modified after approval. Please await approval of the changes.") - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_MODIFIED_AFTER_APPROVAL - if not add_speakerproposal_updated_email(form.instance): - logger.error( - 'Unable to add update email to queue for speaker: {}'.format(form.instance) - ) + if not form.instance.email: + form.instance.email = self.request.user.email - return super().form_valid(form) + speakerproposal = form.save() + + # add speakerproposal to eventproposal + self.eventproposal.speakers.add(speakerproposal) + + # send mail to content team + if not add_new_speakerproposal_email(speakerproposal): + logger.error("Unable to send email to content team after new speakerproposal") + + return redirect( + reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) + ) -class SpeakerProposalSubmitView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, EnsureUnapprovedProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, UpdateView): +class SpeakerProposalUpdateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureUserOwnsProposalMixin, EnsureCFPOpenMixin, UpdateView): + """ + This view allows a user to update an existing SpeakerProposal. + """ model = models.SpeakerProposal - fields = [] - template_name = 'speakerproposal_submit.html' + template_name = 'speakerproposal_form.html' + form_class = SpeakerProposalForm - def get_success_url(self): - return reverse('proposal_list', kwargs={'camp_slug': self.camp.slug}) + def get_form_kwargs(self): + """ + Set camp and eventtype for the form + """ + kwargs = super().get_form_kwargs() + if self.get_object().eventproposals.count() == 1: + # determine which form to use based on the type of event associated with the proposal + eventtype = self.get_object().eventproposals.get().event_type + else: + # more than one eventproposal. If all events are the same type we can still show a non-generic form here + eventtypes = set() + for ep in self.get_object().eventproposals.all(): + eventtypes.add(ep.event_type) + if len(eventtypes) == 1: + eventtype = self.get_object().eventproposals.get().event_type + else: + # more than one type of event for this person, return the generic speakerproposal form + eventtype = None + + # add camp and eventtype to form kwargs + kwargs.update({ + 'camp': self.camp, + 'eventtype': eventtype + }) + + return kwargs def form_valid(self, form): - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_PENDING - messages.info(self.request, "Your proposal has been submitted and is now pending approval") - return super().form_valid(form) + """ + Change the speakerproposal status to pending + """ + # set proposal status to pending + form.instance.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + speakerproposal = form.save() + + # send mail to content team + if not add_speakerproposal_updated_email(speakerproposal): + logger.error("Unable to send email to content team after speakerproposal update") + + # message user and redirect + messages.info(self.request, "Your proposal is now pending approval by the content team.") + return redirect(reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + + +class SpeakerProposalDeleteView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureUserOwnsProposalMixin, EnsureCFPOpenMixin, DeleteView): + """ + This view allows a user to delete an existing SpeakerProposal object, as long as it is not linked to any EventProposals + """ + model = models.SpeakerProposal + template_name = 'proposal_delete.html' + + def get(self, request, *args, **kwargs): + # do not permit deleting if this speakerproposal is linked to any eventproposals + if self.get_object().eventproposals.exists(): + messages.error(request, "Cannot delete a person while it is associated with one or more eventproposals. Delete those first.") + return redirect(reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + + # continue with the request + return super().get(request, *args, **kwargs) + + def get_success_url(self): + messages.success(self.request, "Proposal '%s' has been deleted." % self.object.name) + return reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) + class SpeakerProposalDetailView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, DetailView): @@ -161,101 +252,379 @@ class SpeakerProposalDetailView(LoginRequiredMixin, CampViewMixin, EnsureUserOwn template_name = 'speakerproposal_detail.html' -@method_decorator(require_safe, name='dispatch') -class SpeakerProposalPictureView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, PictureViewMixin, DetailView): +################################################################################################### +# eventproposal views + + +class EventProposalTypeSelectView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, ListView): + """ + This view is for selecting the type of event to submit (when adding a new eventproposal to an existing speakerproposal) + """ + model = models.EventType + template_name = 'event_type_select.html' + + def dispatch(self, request, *args, **kwargs): + """ Get the speakerproposal object """ + self.speaker = get_object_or_404(models.SpeakerProposal, pk=kwargs['speaker_uuid']) + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, **kwargs): + """ We only allow submissions of events with EventTypes where public=True """ + return super().get_queryset().filter(public=True) + + def get_context_data(self, *args, **kwargs): + """ Make speakerproposal object available in template """ + context = super().get_context_data(**kwargs) + context['speaker'] = self.speaker + return context + + +class EventProposalSelectPersonView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, ListView): + """ + This view is for selecting an existing speakerproposal to add to an existing eventproposal + """ model = models.SpeakerProposal + template_name = 'event_proposal_select_person.html' - def get(self, request, *args, **kwargs): - # is the proposal owned by current user? - if self.get_object().user != request.user: - raise Http404() + def dispatch(self, request, *args, **kwargs): + """ Get EventProposal from url kwargs """ + self.eventproposal = get_object_or_404(models.EventProposal, pk=kwargs['event_uuid'], user=request.user) + return super().dispatch(request, *args, **kwargs) - # get and return the response - response = self.get_picture_response('/public/speakerproposals/%(campslug)s/%(proposaluuid)s/%(filename)s' % { - 'campslug': self.camp.slug, - 'proposaluuid': self.get_object().uuid, - 'filename': os.path.basename(self.picture.name), + def get_queryset(self, **kwargs): + """ Filter out any speakerproposals already added to this eventproposal """ + return self.eventproposal.get_available_speakerproposals().all() + + def get_context_data(self, *args, **kwargs): + """ Make eventproposal object available in template """ + context = super().get_context_data(**kwargs) + context['eventproposal'] = self.eventproposal + return context + + +class EventProposalAddPersonView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, UpdateView): + """ + This view is for adding an existing speakerproposal to an existing eventproposal + """ + model = models.EventProposal + template_name = 'event_proposal_add_person.html' + fields = [] + pk_url_kwarg = 'event_uuid' + + def dispatch(self, request, *args, **kwargs): + """ Get the speakerproposal object """ + self.speakerproposal = get_object_or_404(models.SpeakerProposal, pk=kwargs['speaker_uuid'], user=request.user) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + """ Make speakerproposal object available in template """ + context = super().get_context_data(**kwargs) + context['speakerproposal'] = self.speakerproposal + return context + + def form_valid(self, form): + form.instance.speakers.add(self.speakerproposal) + messages.success(self.request, "%s has been added as %s for %s" % ( + self.speakerproposal.name, + form.instance.event_type.host_title, + form.instance.title + )) + return redirect(self.get_success_url()) + + +class EventProposalRemovePersonView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, UpdateView): + """ + This view is for removing a speakerproposal from an existing eventproposal + """ + model = models.EventProposal + template_name = 'event_proposal_remove_person.html' + fields = [] + pk_url_kwarg = 'event_uuid' + + def dispatch(self, request, *args, **kwargs): + """ Get the speakerproposal object and check a few things """ + # get the speakerproposal object from URL kwargs + self.speakerproposal = get_object_or_404(models.SpeakerProposal, pk=kwargs['speaker_uuid'], user=request.user) + return super().dispatch(request, *args, **kwargs) + + + def get_context_data(self, *args, **kwargs): + """ Make speakerproposal object available in template """ + context = super().get_context_data(**kwargs) + context['speakerproposal'] = self.speakerproposal + return context + + def form_valid(self, form): + """ Remove the speaker from the event """ + if self.speakerproposal not in self.get_object().speakers.all(): + # this speaker is not associated with this event + raise Http404 + + if self.get_object().speakers.count() == 1: + messages.error(self.request, "Cannot delete the last person associalted with event!") + return redirect(self.get_success_url()) + + # remove speakerproposal from eventproposal + form.instance.speakers.remove(self.speakerproposal) + messages.success(self.request, "%s has been removed from %s" % ( + self.speakerproposal.name, + self.get_object().title + )) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse( + 'program:eventproposal_detail', kwargs={ + 'camp_slug': self.camp.slug, + 'pk': self.get_object().uuid }) - return response - -class EventProposalCreateView(LoginRequiredMixin, CampViewMixin, CreateProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, CreateView): +class EventProposalCreateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, CreateView): + """ + This view allows a user to create a new eventproposal linked to an existing speakerproposal + """ model = models.EventProposal - fields = ['title', 'abstract', 'event_type', 'speakers', 'allow_video_recording', 'submission_notes'] template_name = 'eventproposal_form.html' + form_class = EventProposalForm - def get_context_data(self, **kwargs): + def dispatch(self, request, *args, **kwargs): + """ Get the speakerproposal object """ + self.speakerproposal = get_object_or_404(models.SpeakerProposal, pk=self.kwargs['speaker_uuid']) + self.event_type = get_object_or_404(models.EventType, slug=self.kwargs['event_type_slug']) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + """ Make speakerproposal object available in template """ context = super().get_context_data(**kwargs) - context['form'].fields['speakers'].queryset = models.SpeakerProposal.objects.filter(camp=self.camp, user=self.request.user) - context['form'].fields['event_type'].queryset = models.EventType.objects.filter(public=True) + context['speaker'] = self.speakerproposal + context['event_type'] = self.event_type return context + def get_form_kwargs(self): + """ + Set camp and eventtype for the form + """ + kwargs = super().get_form_kwargs() + kwargs.update({ + 'camp': self.camp, + 'eventtype': self.event_type + }) + return kwargs -class EventProposalUpdateView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, UpdateView): - model = models.EventProposal - fields = ['title', 'abstract', 'event_type', 'speakers', 'allow_video_recording', 'submission_notes'] - template_name = 'eventproposal_form.html' + def form_valid(self, form): + # set camp and user for this eventproposal + eventproposal = form.save(user=self.request.user, event_type=self.event_type) + + # add the speakerproposal to the eventproposal + eventproposal.speakers.add(self.speakerproposal) + + # send mail to content team + if not add_new_eventproposal_email(eventproposal): + logger.error("Unable to send email to content team after new eventproposal") + + # all good + return redirect(self.get_success_url()) def get_success_url(self): - return reverse('proposal_list', kwargs={'camp_slug': self.camp.slug}) + return reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) - def get_context_data(self, **kwargs): + +class EventProposalUpdateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureUserOwnsProposalMixin, EnsureCFPOpenMixin, UpdateView): + model = models.EventProposal + template_name = 'eventproposal_form.html' + form_class = EventProposalForm + + def get_form_kwargs(self): + """ + Set camp and eventtype for the form + """ + kwargs = super().get_form_kwargs() + kwargs.update({ + 'camp': self.camp, + 'eventtype': self.get_object().event_type + }) + return kwargs + + def get_context_data(self, *args, **kwargs): + """ Make speakerproposal and eventtype objects available in the template """ context = super().get_context_data(**kwargs) - context['form'].fields['speakers'].queryset = models.SpeakerProposal.objects.filter(camp=self.camp, user=self.request.user) - context['form'].fields['event_type'].queryset = models.EventType.objects.filter(public=True) + context['event_type'] = self.get_object().event_type return context def form_valid(self, form): - if form.instance.proposal_status == models.UserSubmittedModel.PROPOSAL_PENDING: - messages.warning(self.request, "Your event proposal has been reverted to status draft. Please submit it again when you are ready.") - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_DRAFT + # set status to pending and save eventproposal + form.instance.proposal_status = models.EventProposal.PROPOSAL_PENDING + eventproposal = form.save() - if form.instance.proposal_status == models.UserSubmittedModel.PROPOSAL_APPROVED: - messages.warning(self.request, "Your event proposal has been set to status modified after approval. Please await approval of the changes.") - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_MODIFIED_AFTER_APPROVAL - if not add_eventproposal_updated_email(form.instance): - logger.error( - 'Unable to add update email to queue for event: {}'.format(form.instance) - ) + # send email to content team + if not add_eventproposal_updated_email(eventproposal): + logger.error("Unable to send email to content team after eventproposal update") - return super().form_valid(form) + # message for the user and redirect + messages.info(self.request, "Your proposal is now pending approval by the content team.") + return redirect(reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) -class EventProposalSubmitView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, EnsureUnapprovedProposalMixin, EnsureWritableCampMixin, EnsureCFSOpenMixin, UpdateView): +class EventProposalDeleteView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureUserOwnsProposalMixin, EnsureCFPOpenMixin, DeleteView): model = models.EventProposal - fields = [] - template_name = 'eventproposal_submit.html' + template_name = 'proposal_delete.html' def get_success_url(self): - return reverse('proposal_list', kwargs={'camp_slug': self.camp.slug}) - - def form_valid(self, form): - form.instance.proposal_status = models.UserSubmittedModel.PROPOSAL_PENDING - messages.info(self.request, "Your proposal has been submitted and is now pending approval") - return super().form_valid(form) + messages.success(self.request, "Proposal '%s' has been deleted." % self.object.title) + return reverse('program:proposal_list', kwargs={'camp_slug': self.camp.slug}) class EventProposalDetailView(LoginRequiredMixin, CampViewMixin, EnsureUserOwnsProposalMixin, DetailView): model = models.EventProposal template_name = 'eventproposal_detail.html' - -################## speakers ############################################### +################################################################################################### +# combined proposal views -@method_decorator(require_safe, name='dispatch') -class SpeakerPictureView(CampViewMixin, PictureViewMixin, DetailView): - model = models.Speaker +class CombinedProposalTypeSelectView(LoginRequiredMixin, CampViewMixin, ListView): + """ + A view which allows the user to select event type without anything else on the page + """ + model = models.EventType + template_name = 'event_type_select.html' + + def get_queryset(self, **kwargs): + """ We only allow submissions of events with EventTypes where public=True """ + return super().get_queryset().filter(public=True) + + +class CombinedProposalPersonSelectView(LoginRequiredMixin, CampViewMixin, ListView): + """ + A view which allows the user to 1) choose between existing SpeakerProposals or + 2) pressing a button to create a new SpeakerProposal. + Redirect straight to 2) if no existing SpeakerProposals exist. + """ + model = models.SpeakerProposal + template_name = 'combined_proposal_select_person.html' + + def dispatch(self, request, *args, **kwargs): + """ + Check that we have a valid EventType + """ + # get EventType from url kwargs + self.eventtype = get_object_or_404(models.EventType, slug=self.kwargs['event_type_slug']) + + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self, **kwargs): + # only show speaker proposals for the current user + return super().get_queryset().filter(user=self.request.user) + + def get_context_data(self, **kwargs): + """ + Add EventType to template context + """ + context = super().get_context_data(**kwargs) + context['eventtype'] = self.eventtype + return context def get(self, request, *args, **kwargs): - # get and return the response - response = self.get_picture_response(path='/public/speakers/%(campslug)s/%(slug)s/%(filename)s' % { - 'campslug': self.camp.slug, - 'slug': self.get_object().slug, - 'filename': os.path.basename(self.picture.name), + """ If we don't have any existing SpeakerProposals just redirect directly to the combined submit view """ + if not self.get_queryset().exists(): + return redirect(reverse_lazy('program:proposal_combined_submit', kwargs={'camp_slug': self.camp.slug, 'event_type_slug': self.eventtype.slug})) + return super().get(request, *args, **kwargs) + + +class CombinedProposalSubmitView(LoginRequiredMixin, CampViewMixin, CreateView): + """ + This view is used by users to submit CFP proposals. + It allows the user to submit an EventProposal and a SpeakerProposal together. + It can also be used with a preselected SpeakerProposal uuid in url kwargs + """ + template_name = 'combined_proposal_submit.html' + + def dispatch(self, request, *args, **kwargs): + """ + Check that we have a valid EventType + """ + # get EventType from url kwargs + self.eventtype = get_object_or_404(models.EventType, slug=self.kwargs['event_type_slug']) + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """ + Add EventType to template context + """ + context = super().get_context_data(**kwargs) + context['eventtype'] = self.eventtype + return context + + def form_valid(self, form): + """ + Save the object(s) here before redirecting + """ + if hasattr(self, 'speakerproposal'): + eventproposal = form.save(user=self.request.user, event_type=self.eventtype) + eventproposal.speakers.add(self.speakerproposal) + else: + # first save the SpeakerProposal + speakerproposal = form['speakerproposal'].save(commit=False) + speakerproposal.camp = self.camp + speakerproposal.user = self.request.user + if not speakerproposal.email: + speakerproposal.email = self.request.user.email + speakerproposal.save() + + # then save the eventproposal + eventproposal = form['eventproposal'].save(user=self.request.user, event_type=self.eventtype) + eventproposal.user = self.request.user + eventproposal.event_type = self.eventtype + eventproposal.save() + + # add the speakerproposal to the eventproposal + eventproposal.speakers.add(speakerproposal) + + # send mail(s) to content team + if not add_new_eventproposal_email(eventproposal): + logger.error("Unable to send email to content team after new eventproposal") + if not hasattr(self, 'speakerproposal'): + if not add_new_speakerproposal_email(speakerproposal): + logger.error("Unable to send email to content team after new speakerproposal") + + # all good + return redirect(reverse_lazy('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + + def get_form_class(self): + """ + Unless we have an existing SpeakerProposal we must show two forms on the page. + We use betterforms.MultiModelForm to combine two forms on the page + """ + if hasattr(self, 'speakerproposal'): + # we already have a speakerproposal, just show an eventproposal form + return EventProposalForm + + # build our MultiModelForm + class CombinedProposalSubmitForm(MultiModelForm): + form_classes = OrderedDict(( + ('speakerproposal', SpeakerProposalForm), + ('eventproposal', EventProposalForm), + )) + + # return the form class + return CombinedProposalSubmitForm + + def get_form_kwargs(self): + """ + Set camp and eventtype for the form + """ + kwargs = super().get_form_kwargs() + kwargs.update({ + 'camp': self.camp, + 'eventtype': self.eventtype }) - return response + return kwargs + + +################################################################################################### +# speaker views class SpeakerDetailView(CampViewMixin, DetailView): @@ -268,7 +637,8 @@ class SpeakerListView(CampViewMixin, ListView): template_name = 'speaker_list.html' -################## events ############################################## +################################################################################################### +# event views class EventListView(CampViewMixin, ListView): @@ -281,7 +651,8 @@ class EventDetailView(CampViewMixin, DetailView): template_name = 'schedule_event_detail.html' -################## schedule ############################################# +################################################################################################### +# schedule views class NoScriptScheduleView(CampViewMixin, TemplateView): @@ -289,27 +660,37 @@ class NoScriptScheduleView(CampViewMixin, TemplateView): 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') + context['eventinstances'] = models.EventInstance.objects.filter( + event__track__camp=self.camp + ).order_by('when') return context - class ScheduleView(CampViewMixin, TemplateView): template_name = 'schedule_overview.html' + def dispatch(self, request, *args, **kwargs): + """ + If no events are scheduled redirect to the event page + """ + response = super().dispatch(request, *args, **kwargs) + if not models.EventInstance.objects.filter(event__track__camp=self.camp).exists(): + return(redirect(reverse('program:event_index', kwargs={'camp_slug': self.camp.slug}))) + return response + def get_context_data(self, *args, **kwargs): context = super(ScheduleView, self).get_context_data(**kwargs) - context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS; + context['schedule_midnight_offset_hours'] = settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS return context -class CallForSpeakersView(CampViewMixin, TemplateView): - def get_template_names(self): - return '%s_call_for_speakers.html' % self.camp.slug +class CallForParticipationView(CampViewMixin, TemplateView): + template_name = 'call_for_participation.html' +################################################################################################### +# control center csv -################## control center ############################################# class ProgramControlCenter(CampViewMixin, TemplateView): template_name = "control/index.html" @@ -331,3 +712,100 @@ class ProgramControlCenter(CampViewMixin, TemplateView): context['csv'] = csv return context + +################################################################################################### +# URL views + +class UrlCreateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, UrlViewMixin, CreateView): + model = models.Url + template_name = 'url_form.html' + fields = ['urltype', 'url'] + + def form_valid(self, form): + """ + Set the proposal FK before saving + Set proposal as pending if it isn't already + """ + if hasattr(self, 'eventproposal') and self.eventproposal: + # this URL belongs to an eventproposal + form.instance.eventproposal = self.eventproposal + url = form.save() + if self.eventproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.eventproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.eventproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.eventproposal.title) + else: + # this URL belongs to a speakerproposal + form.instance.speakerproposal = self.speakerproposal + url = form.save() + if self.speakerproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.speakerproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.speakerproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.speakerproposal.name) + + messages.success(self.request, "URL saved.") + + # all good + return redirect(reverse_lazy('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + + +class UrlUpdateView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, UrlViewMixin, UpdateView): + model = models.Url + template_name = 'url_form.html' + fields = ['urltype', 'url'] + pk_url_kwarg = 'url_uuid' + + def form_valid(self, form): + """ + Set proposal as pending if it isn't already + """ + if hasattr(self, 'eventproposal') and self.eventproposal: + # this URL belongs to a speakerproposal + url = form.save() + if self.eventproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.eventproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.eventproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.eventproposal.title) + else: + # this URL belongs to a speakerproposal + url = form.save() + if self.speakerproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.speakerproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.speakerproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.speakerproposal.name) + + messages.success(self.request, "URL saved.") + + # all good + return redirect(reverse_lazy('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + + +class UrlDeleteView(LoginRequiredMixin, CampViewMixin, EnsureWritableCampMixin, EnsureCFPOpenMixin, UrlViewMixin, DeleteView): + model = models.Url + template_name = 'url_delete.html' + pk_url_kwarg = 'url_uuid' + + def delete(self, request, *args, **kwargs): + """ + Set proposal as pending if it isn't already + """ + if hasattr(self, 'eventproposal') and self.eventproposal: + # this URL belongs to a speakerproposal + if self.eventproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.eventproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.eventproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.eventproposal.title) + else: + # this URL belongs to a speakerproposal + if self.speakerproposal.proposal_status != models.SpeakerProposal.PROPOSAL_PENDING: + self.speakerproposal.proposal_status = models.SpeakerProposal.PROPOSAL_PENDING + self.speakerproposal.save() + messages.success(self.request, "%s is now pending review by the Content Team." % self.speakerproposal.name) + + self.object = self.get_object() + self.object.delete() + messages.success(self.request, "URL deleted.") + + # all good + return redirect(reverse_lazy('program:proposal_list', kwargs={'camp_slug': self.camp.slug})) + diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index 5c860590..00000000 --- a/src/requirements.txt +++ /dev/null @@ -1,43 +0,0 @@ -CommonMark==0.7.3 -Django==1.10.5 -Pillow==4.0.0 -PyPDF2==1.26.0 -Unidecode==0.04.20 -argparse==1.2.1 -asgi-redis==1.3.0 -asyncio==3.4.3 -bleach==1.5.0 - -# fix https://github.com/django/daphne/pull/107 -git+https://github.com/tykling/daphne@ws-x-forwarded-for-bugfix - -# fix https://github.com/django/channels/issues/622 -#channels==1.1.3 -git+https://github.com/tykling/channels@master - -defusedxml==0.4.1 -django-allauth==0.30.0 -django-bleach==0.3.0 -django-bootstrap3==8.2.2 -django-debug-toolbar==1.6 -django-channels-panel==0.0.5 -django-extensions==1.7.7 -django-wkhtmltopdf==3.1.0 -docopt==0.6.2 -future==0.16.0 -html5lib==0.9999999 -icalendar==3.11.3 -ipython==5.3.0 -irc3==0.9.8 -oauthlib==2.0.1 -olefile==0.44 -psycopg2==2.6.2 -python3-openid==3.0.10 -pytz==2016.10 -qrcode==5.3 -requests==2.12.5 -requests-oauthlib==0.7.0 -six==1.10.0 -sqlparse==0.2.2 -venusian==1.0 -webencodings==0.5 diff --git a/src/requirements/dev.txt b/src/requirements/dev.txt new file mode 100644 index 00000000..da53e734 --- /dev/null +++ b/src/requirements/dev.txt @@ -0,0 +1,3 @@ +-r production.txt + +django-debug-toolbar==1.9.1 diff --git a/src/requirements/production.txt b/src/requirements/production.txt new file mode 100644 index 00000000..31f3a5a9 --- /dev/null +++ b/src/requirements/production.txt @@ -0,0 +1,41 @@ +Django==2.1.5 +channels==2.1.6 +channels-redis==2.3.2 + +commonmark==0.8.1 +Pillow==5.4.1 +PyPDF2==1.26.0 +Unidecode==1.0.23 +asyncio==3.4.3 +bleach==3.1.0 +defusedxml==0.5.0 +django-allauth==0.38.0 +django-bleach==0.4.0 +django-bootstrap3==11.0.0 +django-extensions==2.1.4 +django-wkhtmltopdf==3.2.0 +django-reversion==3.0.2 +django-betterforms==1.2 +docopt==0.6.2 +future==0.17.1 +html5lib==1.0.1 +icalendar==4.0.3 +ipython==7.2.0 +irc3==1.1.1 +oauthlib==3.0.0 +olefile==0.46 +psycopg2-binary==2.7.6.1 +python-magic==0.4.15 +python3-openid==3.1.0 +pytz==2018.9 +qrcode==6.1 +requests==2.21.0 +requests-oauthlib==1.2.0 +six==1.12.0 +sqlparse==0.2.4 +venusian==1.2.0 +webencodings==0.5.1 +wrapt==1.11.1 +graphene-django==2.2.0 +django-filter==2.1.0 + diff --git a/src/requirements/test.txt b/src/requirements/test.txt new file mode 100644 index 00000000..a0232901 --- /dev/null +++ b/src/requirements/test.txt @@ -0,0 +1,3 @@ +-r dev.txt + +factory_boy==2.10.0 diff --git a/src/rideshare/__init__.py b/src/rideshare/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rideshare/admin.py b/src/rideshare/admin.py new file mode 100644 index 00000000..6a674531 --- /dev/null +++ b/src/rideshare/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Ride + + +@admin.register(Ride) +class RideModelAdmin(admin.ModelAdmin): + list_display = ('location', 'when', 'seats', 'user') + list_filter = ('camp', 'user') diff --git a/src/rideshare/apps.py b/src/rideshare/apps.py new file mode 100644 index 00000000..9a8b366f --- /dev/null +++ b/src/rideshare/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class RideshareConfig(AppConfig): + name = 'rideshare' diff --git a/src/rideshare/migrations/0001_initial.py b/src/rideshare/migrations/0001_initial.py new file mode 100644 index 00000000..b1a843f3 --- /dev/null +++ b/src/rideshare/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 2.0.4 on 2018-08-08 20:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('camps', '0028_auto_20180525_1025'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Ride', + 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)), + ('seats', models.PositiveIntegerField()), + ('location', models.CharField(max_length=100)), + ('when', models.DateTimeField()), + ('description', models.TextField()), + ('camp', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='camps.Camp')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/rideshare/migrations/0002_auto_20180814_1942.py b/src/rideshare/migrations/0002_auto_20180814_1942.py new file mode 100644 index 00000000..ffcbeff5 --- /dev/null +++ b/src/rideshare/migrations/0002_auto_20180814_1942.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-14 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rideshare', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ride', + name='when', + field=models.DateTimeField(help_text='Format is YYYY-MM-DD HH:mm'), + ), + ] diff --git a/src/rideshare/migrations/__init__.py b/src/rideshare/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rideshare/models.py b/src/rideshare/models.py new file mode 100644 index 00000000..97ce643a --- /dev/null +++ b/src/rideshare/models.py @@ -0,0 +1,30 @@ +from django.db import models +from django.urls import reverse + +from utils.models import UUIDModel, CampRelatedModel + + +class Ride(UUIDModel, CampRelatedModel): + camp = models.ForeignKey('camps.Camp', on_delete=models.PROTECT) + user = models.ForeignKey('auth.User', on_delete=models.PROTECT) + seats = models.PositiveIntegerField() + location = models.CharField(max_length=100) + when = models.DateTimeField(help_text="Format is YYYY-MM-DD HH:mm") + description = models.TextField() + + def get_absolute_url(self): + return reverse( + 'rideshare:detail', + kwargs={ + 'pk': self.pk, + 'camp_slug': self.camp.slug + } + ) + + def __str__(self): + return "{} seats from {} at {} by {}".format( + self.seats, + self.location, + self.when, + self.user + ) diff --git a/src/rideshare/templates/rideshare/emails/contact_mail.html b/src/rideshare/templates/rideshare/emails/contact_mail.html new file mode 100644 index 00000000..6855a686 --- /dev/null +++ b/src/rideshare/templates/rideshare/emails/contact_mail.html @@ -0,0 +1,13 @@ +Hello!
+
+The following message has been submitted to your rideshare on {{ rideshare_url }}.
+
+<message>
+
+ {{ message }}
+
+</message>
+
+Best regards,
+
+The BornHack Teamp diff --git a/src/rideshare/templates/rideshare/emails/contact_mail.txt b/src/rideshare/templates/rideshare/emails/contact_mail.txt new file mode 100644 index 00000000..8225c0d5 --- /dev/null +++ b/src/rideshare/templates/rideshare/emails/contact_mail.txt @@ -0,0 +1,13 @@ +Hello! + +The following message has been submitted to your rideshare on {{ rideshare_url }}. + + + +{{ message }} + + + +Best regards, + +The BornHack Team diff --git a/src/rideshare/templates/rideshare/ride_confirm_delete.html b/src/rideshare/templates/rideshare/ride_confirm_delete.html new file mode 100644 index 00000000..73d954b8 --- /dev/null +++ b/src/rideshare/templates/rideshare/ride_confirm_delete.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block content %} + +
{% csrf_token %} +

Are you sure you want to delete {{ object }}?

+ +
+ +{% endblock %} diff --git a/src/rideshare/templates/rideshare/ride_detail.html b/src/rideshare/templates/rideshare/ride_detail.html new file mode 100644 index 00000000..bb9e90f5 --- /dev/null +++ b/src/rideshare/templates/rideshare/ride_detail.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + +{% block content %} + + + + Back + + +
+ +
+
+

+ {{ object.seats }} + seats free, going from + {{ object.location }} + at + {{ object.when|date:"jS \o\f F \a\t H:i T" }} +

+
+
+ Description: +

+ {{ object.description|untrustedcommonmark }} +

+
+ {% if user == object.user %} + + {% else %} + + + {% endif %} +
+ + + +{% endblock %} diff --git a/src/rideshare/templates/rideshare/ride_form.html b/src/rideshare/templates/rideshare/ride_form.html new file mode 100644 index 00000000..c520b609 --- /dev/null +++ b/src/rideshare/templates/rideshare/ride_form.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + +{% block content %} + +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+ +{% endblock %} diff --git a/src/rideshare/templates/rideshare/ride_list.html b/src/rideshare/templates/rideshare/ride_list.html new file mode 100644 index 00000000..7b39ac5e --- /dev/null +++ b/src/rideshare/templates/rideshare/ride_list.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} + +{% block content %} + + + +

+On this page participants of {{ camp.title }} can communicate about ridesharing to and from the festival. +

+ + + + Create ride + + + +
+ + + + + +{% for ride in ride_list %} + + +
+ When + + Location + + Seats + +
+ {{ ride.when|date:"c" }} + + {{ ride.location }} + + {{ ride.seats }} + + + Details + + +{% empty %} + +
+ No rideshares yet! + +{% endfor %} +
+ +{% endblock %} diff --git a/src/people/tests.py b/src/rideshare/tests.py similarity index 100% rename from src/people/tests.py rename to src/rideshare/tests.py diff --git a/src/rideshare/urls.py b/src/rideshare/urls.py new file mode 100644 index 00000000..e528e248 --- /dev/null +++ b/src/rideshare/urls.py @@ -0,0 +1,43 @@ +from django.urls import path, include + +from .views import ( + RideList, + RideCreate, + RideDetail, + RideUpdate, + RideDelete, +) + +app_name = 'rideshare' + +urlpatterns = [ + path( + '', + RideList.as_view(), + name='list' + ), + path( + 'create/', + RideCreate.as_view(), + name='create' + ), + path( + '/', include([ + path( + '', + RideDetail.as_view(), + name='detail' + ), + path( + 'update/', + RideUpdate.as_view(), + name='update' + ), + path( + 'delete/', + RideDelete.as_view(), + name='delete' + ), + ]) + ) +] diff --git a/src/rideshare/views.py b/src/rideshare/views.py new file mode 100644 index 00000000..3f5ab9fe --- /dev/null +++ b/src/rideshare/views.py @@ -0,0 +1,89 @@ +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.urls import reverse +from django.views.generic import ( + ListView, + DetailView, + CreateView, + UpdateView, + DeleteView, +) +from django.http import HttpResponseRedirect +from django import forms + +from camps.mixins import CampViewMixin +from utils.email import add_outgoing_email + +from .models import Ride + + +class ContactRideForm(forms.Form): + message = forms.CharField( + widget=forms.Textarea(attrs={"placeholder": "Remember to include your contact information!"}), + label="Write a message to this rideshare", + help_text="ATTENTION!: Pressing send will send an email with the above text. It is up to you to include your contact information so the person receiving the email can contact you.", + ) + + +class RideList(LoginRequiredMixin, CampViewMixin, ListView): + model = Ride + + +class RideDetail(LoginRequiredMixin, CampViewMixin, DetailView): + model = Ride + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['form'] = ContactRideForm() + return context + + def post(self, request, **kwargs): + form = ContactRideForm(request.POST) + if form.is_valid(): + ride = self.get_object() + add_outgoing_email( + text_template='rideshare/emails/contact_mail.txt', + to_recipients=[ride.user.emailaddress_set.get(primary=True).email], + formatdict=dict( + rideshare_url="https://bornhack.dk{}".format( + reverse( + 'rideshare:detail', + kwargs={"camp_slug": self.camp.slug, "pk": ride.pk} + ) + ), + message=form.cleaned_data['message'], + ), + subject="BornHack rideshare message!", + ) + messages.info(request, "Your message has been sent.") + return HttpResponseRedirect(ride.get_absolute_url()) + + +class RideCreate(LoginRequiredMixin, CampViewMixin, CreateView): + model = Ride + fields = ['location', 'when', 'seats', 'description'] + + def form_valid(self, form, **kwargs): + ride = form.save(commit=False) + ride.camp = self.camp + ride.user = self.request.user + ride.save() + self.object = ride + return HttpResponseRedirect(self.get_success_url()) + + +class IsRideOwnerMixin(UserPassesTestMixin): + def test_func(self): + return self.get_object().user == self.request.user + + +class RideUpdate(LoginRequiredMixin, CampViewMixin, IsRideOwnerMixin, UpdateView): + model = Ride + fields = ['location', 'when', 'seats', 'description'] + + +class RideDelete(LoginRequiredMixin, CampViewMixin, IsRideOwnerMixin, DeleteView): + model = Ride + + def get_success_url(self): + return reverse('rideshare:list', kwargs={'camp_slug': self.camp.slug}) diff --git a/src/shop/admin.py b/src/shop/admin.py index f264f62a..1605d336 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -1,17 +1,58 @@ from django.contrib import admin -from . import models +from tickets.admin import ShopTicketInline +from .models import ( + CoinifyAPICallback, + CoinifyAPIInvoice, + CoinifyAPIRequest, + EpayCallback, + EpayPayment, + Invoice, + CreditNote, + CustomOrder, + ProductCategory, + Product, + Order, + OrderProductRelation, +) -admin.site.register(models.EpayCallback) -admin.site.register(models.EpayPayment) -admin.site.register(models.CoinifyAPIInvoice) -admin.site.register(models.CoinifyAPICallback) -admin.site.register(models.CoinifyAPIRequest) -admin.site.register(models.Invoice) -admin.site.register(models.CreditNote) +admin.site.register(EpayCallback) +admin.site.register(EpayPayment) +admin.site.register(CoinifyAPIInvoice) +admin.site.register(CoinifyAPICallback) +admin.site.register(CoinifyAPIRequest) -@admin.register(models.CustomOrder) +@admin.register(Invoice) +class InvoiceAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'order', + 'customorder', + 'created', + 'updated', + ] + + +@admin.register(CreditNote) +class CreditNoteAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'user', + 'customer', + 'amount', + 'vat', + 'paid', + 'created', + 'updated', + ] + + list_filter = [ + 'paid' + ] + + +@admin.register(CustomOrder) class CustomOrderAdmin(admin.ModelAdmin): list_display = [ 'id', @@ -20,6 +61,8 @@ class CustomOrderAdmin(admin.ModelAdmin): 'amount', 'vat', 'paid', + 'created', + 'updated', ] list_filter = [ @@ -27,29 +70,61 @@ class CustomOrderAdmin(admin.ModelAdmin): ] -@admin.register(models.ProductCategory) +@admin.register(ProductCategory) class ProductCategoryAdmin(admin.ModelAdmin): list_display = [ 'name', ] -@admin.register(models.Product) +def available_from(product): + if product.available_in.lower: + return product.available_in.lower.strftime("%c") + return "None" +available_from.short_description = 'Available from' + + +def available_to(product): + if product.available_in.upper: + return product.available_in.upper.strftime("%c") + return "None" +available_to.short_description = 'Available to' + + +def stock_info(product): + if product.stock_amount: + return "{} / {}".format( + product.left_in_stock, + product.stock_amount + ) + return "N/A" + + +@admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = [ 'name', 'category', 'ticket_type', 'price', - 'available_in', + stock_info, + available_from, + available_to ] + list_filter = [ + 'category', + 'ticket_type', + ] + + search_fields = ['name'] + class ProductInline(admin.TabularInline): - model = models.OrderProductRelation + model = OrderProductRelation -@admin.register(models.Order) +@admin.register(Order) class OrderAdmin(admin.ModelAdmin): change_form_template = 'admin/change_order_form.html' readonly_fields = ( @@ -86,7 +161,7 @@ class OrderAdmin(admin.ModelAdmin): exclude = ['products'] - inlines = [ProductInline] + inlines = [ProductInline, ShopTicketInline] actions = ['mark_order_as_paid', 'mark_order_as_refunded'] @@ -103,4 +178,3 @@ class OrderAdmin(admin.ModelAdmin): def get_user_email(obj): return obj.order.user.email - diff --git a/src/shop/apps.py b/src/shop/apps.py index 5edbc95c..8e6d60e7 100644 --- a/src/shop/apps.py +++ b/src/shop/apps.py @@ -2,6 +2,7 @@ from django.apps import AppConfig import logging logger = logging.getLogger("bornhack.%s" % __name__) + class ShopConfig(AppConfig): name = 'shop' diff --git a/src/shop/coinify.py b/src/shop/coinify.py index 1077f97b..c5a9e43b 100644 --- a/src/shop/coinify.py +++ b/src/shop/coinify.py @@ -1,13 +1,14 @@ from vendor.coinify.coinify_api import CoinifyAPI -from vendor.coinify.coinify_callback import CoinifyCallback from .models import CoinifyAPIRequest, CoinifyAPIInvoice, CoinifyAPICallback from django.conf import settings -import json, logging +import json +import logging +import requests logger = logging.getLogger("bornhack.%s" % __name__) -def process_coinify_invoice_json(invoicejson, order): +def process_coinify_invoice_json(invoicejson, order, request): # create or update the invoice object in our database coinifyinvoice, created = CoinifyAPIInvoice.objects.update_or_create( coinify_id=invoicejson['id'], @@ -19,7 +20,7 @@ def process_coinify_invoice_json(invoicejson, order): # if the order is paid in full call the mark as paid method now if invoicejson['state'] == 'complete' and not coinifyinvoice.order.paid: - coinifyinvoice.order.mark_as_paid() + coinifyinvoice.order.mark_as_paid(request=request) return coinifyinvoice @@ -34,7 +35,7 @@ def save_coinify_callback(request, order): # now attempt to parse json try: parsed = json.loads(request.body.decode('utf-8')) - except Exception as E: + except Exception: parsed = '' # save this callback to db @@ -83,45 +84,46 @@ def coinify_api_request(api_method, order, **kwargs): return req -def handle_coinify_api_response(req, order): - if req.method == 'invoice_create' or req.method == 'invoice_get': +def handle_coinify_api_response(apireq, order, request): + if apireq.method == 'invoice_create' or apireq.method == 'invoice_get': # Parse api response - if req.response['success']: + if apireq.response['success']: # save this new coinify invoice to the DB coinifyinvoice = process_coinify_invoice_json( - invoicejson = req.response['data'], - order = order, + invoicejson=apireq.response['data'], + order=order, + request=request, ) return coinifyinvoice else: - api_error = req.response['error'] + api_error = apireq.response['error'] logger.error("coinify API error: %s (%s)" % ( api_error['message'], api_error['code'] )) return False else: - logger.error("coinify api method not supported" % req.method) + logger.error("coinify api method not supported" % apireq.method) return False ################### API CALLS ################################################ -def get_coinify_invoice(coinify_invoiceid, order): +def get_coinify_invoice(coinify_invoiceid, order, request): # put args for API request together invoicedict = { 'invoice_id': coinify_invoiceid } # perform the api request - req = coinify_api_request( + apireq = coinify_api_request( api_method='invoice_get', order=order, **invoicedict ) - coinifyinvoice = handle_coinify_api_response(req, order) + coinifyinvoice = handle_coinify_api_response(apireq, order, request) return coinifyinvoice @@ -139,12 +141,12 @@ def create_coinify_invoice(order, request): } # perform the API request - req = coinify_api_request( + apireq = coinify_api_request( api_method='invoice_create', order=order, **invoicedict ) - coinifyinvoice = handle_coinify_api_response(req, order) + coinifyinvoice = handle_coinify_api_response(apireq, order, request) return coinifyinvoice diff --git a/src/shop/context_processors.py b/src/shop/context_processors.py index 676e42b5..ec572d80 100644 --- a/src/shop/context_processors.py +++ b/src/shop/context_processors.py @@ -1,8 +1,5 @@ -from django.conf import settings - - def current_order(request): - if request.user.is_authenticated(): + if request.user.is_authenticated: order = None orders = request.user.orders.filter(open__isnull=False) @@ -11,5 +8,3 @@ def current_order(request): return {'current_order': order} return {} - - diff --git a/src/shop/epay.py b/src/shop/epay.py index dba32ba1..764cec28 100644 --- a/src/shop/epay.py +++ b/src/shop/epay.py @@ -1,6 +1,7 @@ import hashlib from django.conf import settings + def calculate_epay_hash(order, request): hashstring = ( '{merchant_number}{description}11{amount}DKK' @@ -10,9 +11,9 @@ def calculate_epay_hash(order, request): description=order.description, amount=order.total*100, order_id=order.pk, - accept_url = order.get_epay_accept_url(request), - cancel_url = order.get_cancel_url(request), - callback_url = order.get_epay_callback_url(request), + accept_url=order.get_epay_accept_url(request), + cancel_url=order.get_cancel_url(request), + callback_url=order.get_epay_callback_url(request), md5_secret=settings.EPAY_MD5_SECRET, ) epay_hash = hashlib.md5(hashstring.encode('utf-8')).hexdigest() @@ -24,6 +25,7 @@ def validate_epay_callback(query): for key, value in query.items(): if key != 'hash': hashstring += value - hash = hashlib.md5((hashstring + settings.EPAY_MD5_SECRET).encode('utf-8')).hexdigest() + hash = hashlib.md5( + (hashstring + settings.EPAY_MD5_SECRET).encode('utf-8') + ).hexdigest() return hash == query['hash'] - diff --git a/src/shop/factories.py b/src/shop/factories.py new file mode 100644 index 00000000..9b0c5e0c --- /dev/null +++ b/src/shop/factories.py @@ -0,0 +1,51 @@ +import factory + +from factory.django import DjangoModelFactory + +from django.utils import timezone + +from psycopg2.extras import DateTimeTZRange + +from utils.factories import UserFactory + + +class ProductCategoryFactory(DjangoModelFactory): + class Meta: + model = 'shop.ProductCategory' + + name = factory.Faker('word') + + +class ProductFactory(DjangoModelFactory): + class Meta: + model = 'shop.Product' + + name = factory.Faker('word') + slug = factory.Faker('word') + category = factory.SubFactory(ProductCategoryFactory) + description = factory.Faker('paragraph') + price = factory.Faker('pyint') + available_in = factory.LazyFunction( + lambda: + DateTimeTZRange( + lower=timezone.now(), + upper=timezone.now() + timezone.timedelta(31) + ) + ) + + +class OrderFactory(DjangoModelFactory): + class Meta: + model = 'shop.Order' + + user = factory.SubFactory(UserFactory) + + +class OrderProductRelationFactory(DjangoModelFactory): + class Meta: + model = 'shop.OrderProductRelation' + + product = factory.SubFactory(ProductFactory) + order = factory.SubFactory(OrderFactory) + quantity = 1 + handed_out = False diff --git a/src/shop/forms.py b/src/shop/forms.py index 6d6f41af..3d8dcbcd 100644 --- a/src/shop/forms.py +++ b/src/shop/forms.py @@ -1,5 +1,4 @@ from django import forms -from .models import Order class AddToOrderForm(forms.Form): diff --git a/src/shop/invoiceworker.py b/src/shop/invoiceworker.py index cecd51e3..e3d210bb 100644 --- a/src/shop/invoiceworker.py +++ b/src/shop/invoiceworker.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.files import File from utils.pdf import generate_pdf_letter from shop.email import add_invoice_email, add_creditnote_email @@ -39,6 +40,11 @@ def do_work(): template=template, formatdict={ 'invoice': invoice, + 'bank': settings.BANKACCOUNT_BANK, + 'bank_iban': settings.BANKACCOUNT_IBAN, + 'bank_bic': settings.BANKACCOUNT_SWIFTBIC, + 'bank_dk_reg': settings.BANKACCOUNT_REG, + 'bank_dk_accno': settings.BANKACCOUNT_ACCOUNT, }, ) logger.info('Generated pdf for invoice %s' % invoice) diff --git a/src/shop/managers.py b/src/shop/managers.py index e10d1906..dc01c2e6 100644 --- a/src/shop/managers.py +++ b/src/shop/managers.py @@ -1,5 +1,3 @@ -from psycopg2.extras import DateTimeTZRange - from django.db.models import QuerySet from django.utils import timezone diff --git a/src/shop/migrations/0053_auto_20180318_0906.py b/src/shop/migrations/0053_auto_20180318_0906.py new file mode 100644 index 00000000..0acbc182 --- /dev/null +++ b/src/shop/migrations/0053_auto_20180318_0906.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 08:06 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0052_auto_20171004_0005'), + ] + + operations = [ + migrations.AlterField( + model_name='creditnote', + name='user', + field=models.ForeignKey(blank=True, help_text='The user this credit note belongs to, if any.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='creditnotes', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AlterField( + model_name='epaypayment', + name='callback', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shop.EpayCallback'), + ), + migrations.AlterField( + model_name='order', + name='user', + field=models.ForeignKey(help_text='The user this shop order belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='orders', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AlterField( + model_name='orderproductrelation', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shop.Order'), + ), + migrations.AlterField( + model_name='orderproductrelation', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shop.Product'), + ), + migrations.AlterField( + model_name='product', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='shop.ProductCategory'), + ), + migrations.AlterField( + model_name='product', + name='ticket_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='tickets.TicketType'), + ), + ] diff --git a/src/shop/migrations/0054_auto_20180415_1159.py b/src/shop/migrations/0054_auto_20180415_1159.py new file mode 100644 index 00000000..51f19464 --- /dev/null +++ b/src/shop/migrations/0054_auto_20180415_1159.py @@ -0,0 +1,34 @@ +# Generated by Django 2.0.4 on 2018-04-15 16:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0053_auto_20180318_0906'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='stock_amount', + field=models.IntegerField(blank=True, help_text='Initial amount available in stock if there is a limited supply, e.g. fridge space', null=True), + ), + migrations.AlterField( + model_name='epaypayment', + name='order', + field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='shop.Order'), + ), + migrations.AlterField( + model_name='invoice', + name='customorder', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shop.CustomOrder'), + ), + migrations.AlterField( + model_name='invoice', + name='order', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shop.Order'), + ), + ] diff --git a/src/shop/migrations/0055_order_customer_address.py b/src/shop/migrations/0055_order_customer_address.py new file mode 100644 index 00000000..2720cf33 --- /dev/null +++ b/src/shop/migrations/0055_order_customer_address.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-22 13:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0054_auto_20180415_1159'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='customer_address', + field=models.TextField(blank=True, help_text='The additional customer address for this order'), + ), + ] diff --git a/src/shop/migrations/0056_auto_20180827_1020.py b/src/shop/migrations/0056_auto_20180827_1020.py new file mode 100644 index 00000000..fa2aa4d0 --- /dev/null +++ b/src/shop/migrations/0056_auto_20180827_1020.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.4 on 2018-08-27 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0055_order_customer_address'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='customer_address', + ), + migrations.AddField( + model_name='order', + name='invoice_address', + field=models.TextField(blank=True, help_text='The invoice address for this order. Leave blank to use the email associated with the logged in user.'), + ), + ] diff --git a/src/shop/migrations/0057_order_notes.py b/src/shop/migrations/0057_order_notes.py new file mode 100644 index 00000000..2e0aee52 --- /dev/null +++ b/src/shop/migrations/0057_order_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-08-27 10:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0056_auto_20180827_1020'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='notes', + field=models.TextField(blank=True, default='', help_text='Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.'), + ), + ] diff --git a/src/shop/models.py b/src/shop/models.py index 4ebcc7c8..e4ee2751 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -1,8 +1,4 @@ -import io import logging -import hashlib -import base64 -import qrcode from django.conf import settings from django.db import models @@ -12,7 +8,7 @@ from django.contrib.postgres.fields import DateTimeRangeField, JSONField 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 django.urls import reverse_lazy from django.core.exceptions import ValidationError from decimal import Decimal from datetime import timedelta @@ -76,6 +72,7 @@ class Order(CreatedUpdatedModel): verbose_name=_('User'), help_text=_('The user this shop order belongs to.'), related_name='orders', + on_delete=models.PROTECT, ) paid = models.BooleanField( @@ -131,6 +128,17 @@ class Order(CreatedUpdatedModel): blank=True, ) + invoice_address = models.TextField( + help_text=_('The invoice address for this order. Leave blank to use the email associated with the logged in user.'), + blank=True + ) + + notes = models.TextField( + help_text='Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.', + default='', + blank=True, + ) + objects = OrderQuerySet.as_manager() def __str__(self): @@ -199,9 +207,10 @@ class Order(CreatedUpdatedModel): product=order_product.product, ) ticket.save() - messages.success(request, "Created %s tickets of type: %s" % (order_product.quantity, order_product.product.ticket_type.name)) + if request: + messages.success(request, "Created %s tickets of type: %s" % (order_product.quantity, order_product.product.ticket_type.name)) # and mark the OPR as handed_out=True - order_product.handed_out=True + order_product.handed_out = True order_product.save() self.save() @@ -209,8 +218,8 @@ class Order(CreatedUpdatedModel): if not self.paid: messages.error(request, "Order %s is not paid, so cannot mark it as refunded!" % self.pk) else: - self.refunded=True - ### delete any tickets related to this order + self.refunded = True + # delete any tickets related to this order if self.tickets.all(): messages.success(request, "Order %s marked as refunded, deleting %s tickets..." % (self.pk, self.tickets.count())) self.tickets.all().delete() @@ -258,7 +267,6 @@ class Order(CreatedUpdatedModel): if not self.coinify_api_invoices.exists(): return False - coinifyinvoice = None for tempinvoice in self.coinify_api_invoices.all(): # we already have a coinifyinvoice for this order, check if it expired if not tempinvoice.expired: @@ -294,7 +302,8 @@ class Product(CreatedUpdatedModel, UUIDModel): category = models.ForeignKey( 'shop.ProductCategory', - related_name='products' + related_name='products', + on_delete=models.PROTECT, ) name = models.CharField(max_length=150) @@ -315,6 +324,16 @@ class Product(CreatedUpdatedModel, UUIDModel): ticket_type = models.ForeignKey( 'tickets.TicketType', + on_delete=models.PROTECT, + null=True, + blank=True + ) + + stock_amount = models.IntegerField( + help_text=( + 'Initial amount available in stock if there is a limited ' + 'supply, e.g. fridge space' + ), null=True, blank=True ) @@ -334,8 +353,23 @@ class Product(CreatedUpdatedModel, UUIDModel): ) def is_available(self): + """ Is the product available or not? + + Checks for the following: + + - Whether now is in the self.available_in + - If a stock is defined, that there are items left + """ + predicates = [self.is_time_available] + if self.stock_amount: + predicates.append(self.is_stock_available) + return all(predicates) + + @property + def is_time_available(self): now = timezone.now() - return now in self.available_in + time_available = now in self.available_in + return time_available def is_old(self): now = timezone.now() @@ -347,10 +381,30 @@ class Product(CreatedUpdatedModel, UUIDModel): now = timezone.now() return self.available_in.lower > now + @property + def left_in_stock(self): + if self.stock_amount: + sold = OrderProductRelation.objects.filter( + product=self, + order__paid=True, + ).aggregate(Sum('quantity'))['quantity__sum'] + + total_left = self.stock_amount - (sold or 0) + + return total_left + return None + + @property + def is_stock_available(self): + if self.stock_amount: + stock_available = self.left_in_stock > 0 + return stock_available + # If there is no stock defined the product is generally available. + return True class OrderProductRelation(CreatedUpdatedModel): - order = models.ForeignKey('shop.Order') - product = models.ForeignKey('shop.Product') + order = models.ForeignKey('shop.Order', on_delete=models.PROTECT) + product = models.ForeignKey('shop.Product', on_delete=models.PROTECT) quantity = models.PositiveIntegerField() handed_out = models.BooleanField(default=False) @@ -377,8 +431,8 @@ class EpayPayment(CreatedUpdatedModel, UUIDModel): verbose_name = 'Epay Payment' verbose_name_plural = 'Epay Payments' - order = models.OneToOneField('shop.Order') - callback = models.ForeignKey('shop.EpayCallback') + order = models.OneToOneField('shop.Order', on_delete=models.PROTECT) + callback = models.ForeignKey('shop.EpayCallback', on_delete=models.PROTECT) txnid = models.IntegerField() @@ -406,6 +460,7 @@ class CreditNote(CreatedUpdatedModel): verbose_name=_('User'), help_text=_('The user this credit note belongs to, if any.'), related_name='creditnotes', + on_delete=models.PROTECT, null=True, blank=True ) @@ -469,8 +524,18 @@ class CreditNote(CreatedUpdatedModel): class Invoice(CreatedUpdatedModel): - order = models.OneToOneField('shop.Order', null=True, blank=True) - customorder = models.OneToOneField('shop.CustomOrder', null=True, blank=True) + order = models.OneToOneField( + 'shop.Order', + null=True, + blank=True, + on_delete=models.PROTECT + ) + customorder = models.OneToOneField( + 'shop.CustomOrder', + null=True, + blank=True, + on_delete=models.PROTECT + ) pdf = models.FileField(null=True, blank=True, upload_to='invoices/') sent_to_customer = models.BooleanField(default=False) @@ -511,7 +576,7 @@ class CoinifyAPIInvoice(CreatedUpdatedModel): @property def expired(self): - return parse_datetime(self.invoicejson['expire_time']) < timezone.now() + return parse_datetime(self.invoicejson['expire_time']) < timezone.now() class CoinifyAPICallback(CreatedUpdatedModel): diff --git a/src/shop/templates/bank_transfer.html b/src/shop/templates/bank_transfer.html index 3a475065..c20c8759 100644 --- a/src/shop/templates/bank_transfer.html +++ b/src/shop/templates/bank_transfer.html @@ -11,10 +11,11 @@ of the full amount of {{ total|currency }} using the fol
  • Swift/BIC: {{ swiftbic }} (Danish reg. no. {{ regno }})
  • IBAN: {{ iban }} (Danish account no. {{ accountno }})
  • +
  • Danish bank transfer: Reg: {{ regno }} Account: {{ accountno }}
  • Reference*: bornhack #{{ orderid }}
-