Merge branch 'master' into 2-factor-auth
This commit is contained in:
commit
563bd8a59d
29
LICENSE
Normal file
29
LICENSE
Normal file
|
@ -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.
|
71
README.md
71
README.md
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
Django project to power Bornhack. Features include news, villages, webshop, and more.
|
Django project to power Bornhack. Features include news, villages, webshop, and more.
|
||||||
|
|
||||||
## Setup
|
## Development setup
|
||||||
|
|
||||||
### Clone the repo
|
### Clone the repo
|
||||||
Clone with --recursive to include submodules:
|
Clone with --recursive to include submodules:
|
||||||
|
|
||||||
git clone --recursive https://github.com/bornhack/bornhack-website
|
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
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
@ -20,60 +20,72 @@ $ virtualenv venv -p python3
|
||||||
$ source venv/bin/activate
|
$ 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
|
### System libraries
|
||||||
Install system dependencies (method depends on OS):
|
Install system dependencies (method depends on OS):
|
||||||
- postgresql headers (for psycopg2):
|
- postgresql headers (for psycopg2):
|
||||||
- Debian: libpq-dev
|
- Debian: libpq-dev
|
||||||
- FreeBSD: databases/postgresql93-client
|
- FreeBSD: databases/postgresql93-client
|
||||||
|
- macOS: If using the PostgreSQL.app, the headers are included, only path needs to be added
|
||||||
- libjpeg (for pdf generation)
|
- libjpeg (for pdf generation)
|
||||||
- Debian: libjpeg-dev
|
- Debian: libjpeg-dev
|
||||||
- FreeBSD: graphics/jpeg-turbo
|
- FreeBSD: graphics/jpeg-turbo
|
||||||
|
- macOS: brew install libjpeg
|
||||||
|
- libmagic (might already be installed)
|
||||||
|
- macOS: brew install libmagic
|
||||||
- wkhtmltopdf (also for pdf generation):
|
- wkhtmltopdf (also for pdf generation):
|
||||||
- Debian: wkhtmltopdf
|
- Debian: wkhtmltopdf
|
||||||
- FreeBSD: converters/wkhtmltopdf
|
- FreeBSD: converters/wkhtmltopdf
|
||||||
|
- macOS: install from https://wkhtmltopdf.org/
|
||||||
- fonts
|
- fonts
|
||||||
- Debian: ?
|
- Debian: ?
|
||||||
- FreeBSD: x11-fonts/webfonts
|
- FreeBSD: x11-fonts/webfonts
|
||||||
|
- macOS: ?
|
||||||
|
|
||||||
### Python packages
|
### Python packages
|
||||||
Install pip 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
|
### 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
|
Edit the configuration file, setting up `DATABASES` matching your Postgres settings.
|
||||||
(intended for Ansible).
|
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
Is this a new installation? Initialize the 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:
|
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
|
### Done
|
||||||
Is this for local development? Start the Django devserver:
|
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.
|
Otherwise start uwsgi or similar to serve the application.
|
||||||
|
@ -87,7 +99,7 @@ Enjoy!
|
||||||
Add a new camp by running:
|
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
|
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-large.png`
|
||||||
* `{camp-slug}-logo-small.png`
|
* `{camp-slug}-logo-small.png`
|
||||||
|
|
||||||
### multicamp prod migration notes
|
## Contributors
|
||||||
|
* Alexander Færøy https://github.com/ahf
|
||||||
* when villages.0008 migration fails go add camp_id to all existing villages
|
* Benjamin Bach https://github.com/benjaoming
|
||||||
* go to admin interface and add bornhack 2017, and set slug for bornhack 2016
|
* coral https://github.com/coral
|
||||||
* convert events to the new format (somehow)
|
* 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
|
||||||
|
|
|
@ -46,8 +46,6 @@ speakerDecoder =
|
||||||
|> required "name" string
|
|> required "name" string
|
||||||
|> required "slug" string
|
|> required "slug" string
|
||||||
|> required "biography" string
|
|> required "biography" string
|
||||||
|> optional "large_picture_url" (nullable string) Nothing
|
|
||||||
|> optional "small_picture_url" (nullable string) Nothing
|
|
||||||
|
|
||||||
|
|
||||||
eventDecoder : Decoder Event
|
eventDecoder : Decoder Event
|
||||||
|
@ -82,6 +80,7 @@ eventInstanceDecoder =
|
||||||
|> required "url" string
|
|> required "url" string
|
||||||
|> required "event_slug" string
|
|> required "event_slug" string
|
||||||
|> required "event_type" string
|
|> required "event_type" string
|
||||||
|
|> required "event_track" string
|
||||||
|> required "bg-color" string
|
|> required "bg-color" string
|
||||||
|> required "fg-color" string
|
|> required "fg-color" string
|
||||||
|> required "from" dateDecoder
|
|> required "from" dateDecoder
|
||||||
|
@ -111,6 +110,13 @@ eventTypeDecoder =
|
||||||
|> required "light_text" bool
|
|> 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 : Decoder (Flags -> Filter -> Location -> Route -> Bool -> Model)
|
||||||
initDataDecoder =
|
initDataDecoder =
|
||||||
decode Model
|
decode Model
|
||||||
|
@ -119,4 +125,5 @@ initDataDecoder =
|
||||||
|> required "event_instances" (list eventInstanceDecoder)
|
|> required "event_instances" (list eventInstanceDecoder)
|
||||||
|> required "event_locations" (list eventLocationDecoder)
|
|> required "event_locations" (list eventLocationDecoder)
|
||||||
|> required "event_types" (list eventTypeDecoder)
|
|> required "event_types" (list eventTypeDecoder)
|
||||||
|
|> required "event_tracks" (list eventTrackDecoder)
|
||||||
|> required "speakers" (list speakerDecoder)
|
|> required "speakers" (list speakerDecoder)
|
||||||
|
|
|
@ -34,10 +34,10 @@ init flags location =
|
||||||
parseLocation location
|
parseLocation location
|
||||||
|
|
||||||
emptyFilter =
|
emptyFilter =
|
||||||
Filter [] [] []
|
Filter [] [] [] []
|
||||||
|
|
||||||
model =
|
model =
|
||||||
Model [] [] [] [] [] [] flags emptyFilter location currentRoute False
|
Model [] [] [] [] [] [] [] flags emptyFilter location currentRoute False
|
||||||
in
|
in
|
||||||
model ! [ sendInitMessage flags.camp_slug flags.websocket_server ]
|
model ! [ sendInitMessage flags.camp_slug flags.websocket_server ]
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@ type alias Model =
|
||||||
, eventInstances : List EventInstance
|
, eventInstances : List EventInstance
|
||||||
, eventLocations : List FilterType
|
, eventLocations : List FilterType
|
||||||
, eventTypes : List FilterType
|
, eventTypes : List FilterType
|
||||||
|
, eventTracks : List FilterType
|
||||||
, speakers : List Speaker
|
, speakers : List Speaker
|
||||||
, flags : Flags
|
, flags : Flags
|
||||||
, filter : Filter
|
, filter : Filter
|
||||||
|
@ -69,8 +70,6 @@ type alias Speaker =
|
||||||
{ name : String
|
{ name : String
|
||||||
, slug : SpeakerSlug
|
, slug : SpeakerSlug
|
||||||
, biography : String
|
, biography : String
|
||||||
, largePictureUrl : Maybe String
|
|
||||||
, smallPictureUrl : Maybe String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -81,6 +80,7 @@ type alias EventInstance =
|
||||||
, url : String
|
, url : String
|
||||||
, eventSlug : EventSlug
|
, eventSlug : EventSlug
|
||||||
, eventType : String
|
, eventType : String
|
||||||
|
, eventTrack : String
|
||||||
, backgroundColor : String
|
, backgroundColor : String
|
||||||
, forgroundColor : String
|
, forgroundColor : String
|
||||||
, from : Date
|
, from : Date
|
||||||
|
@ -142,11 +142,13 @@ type FilterType
|
||||||
= TypeFilter FilterName FilterSlug TypeColor TypeLightText
|
= TypeFilter FilterName FilterSlug TypeColor TypeLightText
|
||||||
| LocationFilter FilterName FilterSlug LocationIcon
|
| LocationFilter FilterName FilterSlug LocationIcon
|
||||||
| VideoFilter FilterName FilterSlug
|
| VideoFilter FilterName FilterSlug
|
||||||
|
| TrackFilter FilterName FilterSlug
|
||||||
|
|
||||||
|
|
||||||
type alias Filter =
|
type alias Filter =
|
||||||
{ eventTypes : List FilterType
|
{ eventTypes : List FilterType
|
||||||
, eventLocations : List FilterType
|
, eventLocations : List FilterType
|
||||||
|
, eventTracks : List FilterType
|
||||||
, videoRecording : List FilterType
|
, videoRecording : List FilterType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,6 +164,9 @@ unpackFilterType filter =
|
||||||
VideoFilter name slug ->
|
VideoFilter name slug ->
|
||||||
( name, slug )
|
( name, slug )
|
||||||
|
|
||||||
|
TrackFilter name slug ->
|
||||||
|
( name, slug )
|
||||||
|
|
||||||
|
|
||||||
getSlugFromFilterType filter =
|
getSlugFromFilterType filter =
|
||||||
let
|
let
|
||||||
|
|
|
@ -96,6 +96,19 @@ update msg model =
|
||||||
videoRecording :: model.filter.videoRecording
|
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 =
|
query =
|
||||||
filterToQuery newFilter
|
filterToQuery newFilter
|
||||||
|
|
||||||
|
|
|
@ -69,7 +69,7 @@ locationColumns eventInstances eventLocations offset minutes =
|
||||||
, ( "justify-content", "space-around" )
|
, ( "justify-content", "space-around" )
|
||||||
]
|
]
|
||||||
, classList
|
, classList
|
||||||
[ ( "col-sm-11", True )
|
[ ( "col-xs-11", True )
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
(List.map (\location -> locationColumn columnWidth eventInstances offset minutes location) eventLocations)
|
(List.map (\location -> locationColumn columnWidth eventInstances offset minutes location) eventLocations)
|
||||||
|
@ -269,7 +269,7 @@ gutter : List Date -> Html Msg
|
||||||
gutter hours =
|
gutter hours =
|
||||||
div
|
div
|
||||||
[ classList
|
[ classList
|
||||||
[ ( "col-sm-1", True )
|
[ ( "col-xs-1", True )
|
||||||
, ( "day-view-gutter", True )
|
, ( "day-view-gutter", True )
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
|
@ -123,14 +123,13 @@ eventDetailSidebar event model =
|
||||||
]
|
]
|
||||||
(videoRecordingLink
|
(videoRecordingLink
|
||||||
++ [ speakerSidebar speakers
|
++ [ speakerSidebar speakers
|
||||||
, eventMetaDataSidebar event
|
, eventMetaDataSidebar event eventInstances model
|
||||||
, eventInstancesSidebar eventInstances
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
eventMetaDataSidebar : Event -> Html Msg
|
eventMetaDataSidebar : Event -> List EventInstance -> Model -> Html Msg
|
||||||
eventMetaDataSidebar event =
|
eventMetaDataSidebar event eventInstances model =
|
||||||
let
|
let
|
||||||
( showVideoRecoring, videoRecording ) =
|
( showVideoRecoring, videoRecording ) =
|
||||||
case event.videoState of
|
case event.videoState of
|
||||||
|
@ -142,9 +141,22 @@ eventMetaDataSidebar event =
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
( False, "" )
|
( False, "" )
|
||||||
|
|
||||||
|
eventInstanceMetaData =
|
||||||
|
case eventInstances of
|
||||||
|
[ instance ] ->
|
||||||
|
eventInstanceItem instance model
|
||||||
|
|
||||||
|
instances ->
|
||||||
|
[ h4 []
|
||||||
|
[ text "Multiple occurences:" ]
|
||||||
|
, ul
|
||||||
|
[]
|
||||||
|
(List.map (\ei -> li [] <| eventInstanceItem ei model) instances)
|
||||||
|
]
|
||||||
in
|
in
|
||||||
div []
|
div []
|
||||||
[ h4 [] [ text "Metadata" ]
|
([ h4 [] [ text "Metadata" ]
|
||||||
, ul []
|
, ul []
|
||||||
([ li [] [ strong [] [ text "Type: " ], text event.eventType ]
|
([ li [] [ strong [] [ text "Type: " ], text event.eventType ]
|
||||||
]
|
]
|
||||||
|
@ -157,6 +169,43 @@ 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
|
speakerSidebar : List Speaker -> Html Msg
|
||||||
|
@ -175,32 +224,3 @@ speakerDetail speaker =
|
||||||
li []
|
li []
|
||||||
[ a [ href <| routeToString <| SpeakerRoute speaker.slug ] [ text speaker.name ]
|
[ 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)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
|
@ -37,6 +37,9 @@ applyFilters day model =
|
||||||
locations =
|
locations =
|
||||||
slugs model.eventLocations model.filter.eventLocations
|
slugs model.eventLocations model.filter.eventLocations
|
||||||
|
|
||||||
|
tracks =
|
||||||
|
slugs model.eventTracks model.filter.eventTracks
|
||||||
|
|
||||||
videoFilters =
|
videoFilters =
|
||||||
slugs videoRecordingFilters model.filter.videoRecording
|
slugs videoRecordingFilters model.filter.videoRecording
|
||||||
|
|
||||||
|
@ -47,6 +50,7 @@ applyFilters day model =
|
||||||
&& (Date.Extra.equalBy Date.Extra.Day eventInstance.from day.date)
|
&& (Date.Extra.equalBy Date.Extra.Day eventInstance.from day.date)
|
||||||
&& List.member eventInstance.location locations
|
&& List.member eventInstance.location locations
|
||||||
&& List.member eventInstance.eventType types
|
&& List.member eventInstance.eventType types
|
||||||
|
&& List.member eventInstance.eventTrack tracks
|
||||||
&& List.member eventInstance.videoState videoFilters
|
&& List.member eventInstance.videoState videoFilters
|
||||||
)
|
)
|
||||||
model.eventInstances
|
model.eventInstances
|
||||||
|
@ -77,6 +81,12 @@ filterSidebar model =
|
||||||
model.filter.eventLocations
|
model.filter.eventLocations
|
||||||
model.eventInstances
|
model.eventInstances
|
||||||
.location
|
.location
|
||||||
|
, filterView
|
||||||
|
"Track"
|
||||||
|
model.eventTracks
|
||||||
|
model.filter.eventTracks
|
||||||
|
model.eventInstances
|
||||||
|
.eventTrack
|
||||||
, filterView
|
, filterView
|
||||||
"Video"
|
"Video"
|
||||||
videoRecordingFilters
|
videoRecordingFilters
|
||||||
|
@ -214,10 +224,10 @@ filterChoiceView filter currentFilters eventInstances slugLike =
|
||||||
"film"
|
"film"
|
||||||
|
|
||||||
"to-be-recorded" ->
|
"to-be-recorded" ->
|
||||||
"video-camera"
|
"video"
|
||||||
|
|
||||||
"not-to-be-recorded" ->
|
"not-to-be-recorded" ->
|
||||||
"ban"
|
"video-slash"
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
""
|
""
|
||||||
|
@ -309,11 +319,15 @@ parseFilterFromQuery query model =
|
||||||
locations =
|
locations =
|
||||||
getFilter "location" model.eventLocations query
|
getFilter "location" model.eventLocations query
|
||||||
|
|
||||||
|
tracks =
|
||||||
|
getFilter "tracks" model.eventTracks query
|
||||||
|
|
||||||
videoFilters =
|
videoFilters =
|
||||||
getFilter "video" videoRecordingFilters query
|
getFilter "video" videoRecordingFilters query
|
||||||
in
|
in
|
||||||
{ eventTypes = types
|
{ eventTypes = types
|
||||||
, eventLocations = locations
|
, eventLocations = locations
|
||||||
|
, eventTracks = tracks
|
||||||
, videoRecording = videoFilters
|
, videoRecording = videoFilters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -80,25 +80,25 @@ dayEventInstanceIcons eventInstance =
|
||||||
case eventInstance.videoState of
|
case eventInstance.videoState of
|
||||||
"has-recording" ->
|
"has-recording" ->
|
||||||
[ i
|
[ 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" ->
|
"to-be-recorded" ->
|
||||||
[ i
|
[ 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" ->
|
"not-to-be-recorded" ->
|
||||||
[ i
|
[ i
|
||||||
[ classList [ ( "fa", True ), ( "fa-ban", True ), ( "pull-right", True ) ] ]
|
[ classList [ ( "fa", True ), ( "fa-video-slash", True ), ( "pull-right", True ), ( "fa-fw", True ) ] ]
|
||||||
[]
|
[]
|
||||||
]
|
]
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
[]
|
[]
|
||||||
in
|
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
|
++ videoIcon
|
||||||
|
|
|
@ -22,24 +22,11 @@ speakerDetailView speakerSlug model =
|
||||||
model.speakers
|
model.speakers
|
||||||
|> List.filter (\speaker -> speaker.slug == speakerSlug)
|
|> List.filter (\speaker -> speaker.slug == speakerSlug)
|
||||||
|> List.head
|
|> List.head
|
||||||
|
|
||||||
image =
|
|
||||||
case speaker of
|
|
||||||
Just speaker ->
|
|
||||||
case speaker.smallPictureUrl of
|
|
||||||
Just smallPictureUrl ->
|
|
||||||
[ img [ src smallPictureUrl ] [] ]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
[]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
[]
|
|
||||||
in
|
in
|
||||||
case speaker of
|
case speaker of
|
||||||
Just speaker ->
|
Just speaker ->
|
||||||
div []
|
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 ) ] ] []
|
[ i [ classList [ ( "fa", True ), ( "fa-chevron-left", True ) ] ] []
|
||||||
, text " Back"
|
, text " Back"
|
||||||
]
|
]
|
||||||
|
@ -47,8 +34,6 @@ speakerDetailView speakerSlug model =
|
||||||
, div [] [ Markdown.toHtml [] speaker.biography ]
|
, div [] [ Markdown.toHtml [] speaker.biography ]
|
||||||
, speakerEvents speaker model
|
, speakerEvents speaker model
|
||||||
]
|
]
|
||||||
++ image
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
Nothing ->
|
||||||
div [] [ text "Unknown speaker..." ]
|
div [] [ text "Unknown speaker..." ]
|
||||||
|
|
69
scripts/schemagif.sh
Executable file
69
scripts/schemagif.sh
Executable file
|
@ -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
|
||||||
|
|
30
src/backoffice/mixins.py
Normal file
30
src/backoffice/mixins.py
Normal file
|
@ -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")
|
||||||
|
|
42
src/backoffice/templates/approve_public_credit_names.html
Normal file
42
src/backoffice/templates/approve_public_credit_names.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Approve Public Credit Names</h2>
|
||||||
|
<div class="lead">
|
||||||
|
Use this view to approve users public credit names.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Public Credit Name</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ profile.user.username }}</td>
|
||||||
|
<td>{{ profile.user.email }}</td>
|
||||||
|
<td>{{ profile.public_credit_name }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/profiles/profile/{{ profile.pk }}/change/" class="btn btn-success">Open In Admin</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% load commonmark %}
|
|
||||||
{% load static from staticfiles %}
|
|
||||||
{% load imageutils %}
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<h2>BornHack Backoffice</h2>
|
|
||||||
<div class="lead">
|
|
||||||
Welcome to the promised land! Please select your desired action below:
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<p>
|
|
||||||
<div class="list-group">
|
|
||||||
<a href="{% url 'backoffice:product_handout' %}" class="list-group-item">
|
|
||||||
<h4 class="list-group-item-heading">Hand Out Products</h4>
|
|
||||||
<p class="list-group-item-text">Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.</p>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'backoffice:ticket_checkin' %}" class="list-group-item">
|
|
||||||
<h4 class="list-group-item-heading">Check-In Tickets</h4>
|
|
||||||
<p class="list-group-item-text">Use this view to check-in tickets when participants arrive.</p>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'backoffice:badge_handout' %}" class="list-group-item">
|
|
||||||
<h4 class="list-group-item-heading">Hand Out Badges</h4>
|
|
||||||
<p class="list-group-item-text">Use this view to mark badges as handed out.</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Hand Out Badges</h2>
|
<h2>Hand Out Badges</h2>
|
||||||
<div class="lead">
|
<div class="lead">
|
||||||
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 <a href="{% url 'backoffice:ticket_checkin' %}">Ticket Checkin view</a> instead. To hand out merchandise and other products go to the <a href="{% url 'backoffice:product_handout' %}">Hand Out Products</a> view instead.
|
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 <a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}">Ticket Checkin view</a> instead. To hand out merchandise and other products go to the <a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}">Hand Out Products</a> view instead.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
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.
|
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.
|
||||||
|
@ -47,12 +47,5 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
$(document).ready(function(){
|
|
||||||
$('.table').DataTable();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
|
||||||
|
|
24
src/backoffice/templates/camp_select.html
Normal file
24
src/backoffice/templates/camp_select.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<h2>BornHack Backoffice Camp Picker</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<p>
|
||||||
|
<div class="list-group">
|
||||||
|
{% for camp in camp_list %}
|
||||||
|
<a href="{% url 'backoffice:camp_index' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">{{ camp.title }}</h4>
|
||||||
|
<p class="list-group-item-text">Manage {{ camp.title }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
23
src/backoffice/templates/expense_detail_backoffice.html
Normal file
23
src/backoffice/templates/expense_detail_backoffice.html
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Manage Expense</h3>
|
||||||
|
|
||||||
|
{% include 'includes/expense_detail_panel.html' %}
|
||||||
|
|
||||||
|
{% if expense.approved == None %}
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Approve Expense" button_type="submit" button_class="btn-success" name="approve" %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-times'></i> Reject Expense" button_type="submit" button_class="btn-danger" name="reject" %}
|
||||||
|
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Back to Expense List</a>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
28
src/backoffice/templates/expense_list_backoffice.html
Normal file
28
src/backoffice/templates/expense_list_backoffice.html
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Manage Expenses for {{ camp.title }}</h2>
|
||||||
|
|
||||||
|
{% if unapproved_expenses %}
|
||||||
|
<div class="lead">
|
||||||
|
This table shows unapproved expenses for {{ camp.title }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'includes/expense_list_panel.html' with expense_list=unapproved_expenses %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="lead">
|
||||||
|
This table shows all approved expenses for {{ camp.title }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'includes/expense_list_panel.html' %}
|
||||||
|
|
||||||
|
{% endblock content %}
|
84
src/backoffice/templates/index.html
Normal file
84
src/backoffice/templates/index.html
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<h2>{{ camp.title }} Backoffice</h2>
|
||||||
|
<div class="lead">
|
||||||
|
Welcome to the promised land! Please select your desired action below:
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<p>
|
||||||
|
<div class="list-group">
|
||||||
|
{% if perms.camps.infoteam_permission %}
|
||||||
|
<h3>Info Team</h3>
|
||||||
|
<a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Hand Out Products</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Check-In Tickets</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to check-in tickets when participants arrive.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Hand Out Badges</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to mark badges as handed out.</p>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if perms.camps.contentteam_permission %}
|
||||||
|
<h3>Content Team</h3>
|
||||||
|
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Manage Proposals</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to manage SpeakerProposals and EventProposals</p>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if perms.camps.orgateam_permission %}
|
||||||
|
<h3>Orga Team</h3>
|
||||||
|
<a href="{% url 'backoffice:public_credit_names' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Approve Public Credit Names</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to check and approve users Public Credit Names</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:merchandise_orders' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Merchandise Orders</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to look at Merchandise Orders</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:merchandise_to_order' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Merchandise To Order</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to generate a list of merchandise that needs to be ordered</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:village_orders' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Village Orders</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to look at Village category OrderProductRelations</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:village_to_order' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Village Gear To Order</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to generate a list of village gear that needs to be ordered</p>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if perms.camps.economyteam_permission %}
|
||||||
|
<h3>Economy Team</h3>
|
||||||
|
<a href="{% url 'backoffice:expense_list' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Expenses</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to see and approve/reject expenses.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Reimbursements</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to view and create reimbursements for approved expenses.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">Revenues</h4>
|
||||||
|
<p class="list-group-item-text">Use this view to see and approve/reject revenues.</p>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
15
src/backoffice/templates/manage_eventproposal.html
Normal file
15
src/backoffice/templates/manage_eventproposal.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Manage {{ form.instance.event_type.name }} Proposal</h3>
|
||||||
|
{% include 'includes/eventproposal_detail.html' with camp=camp %}
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-times'></i> Reject" button_type="submit" button_class="btn-danger" name="reject" %}
|
||||||
|
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
83
src/backoffice/templates/manage_proposals.html
Normal file
83
src/backoffice/templates/manage_proposals.html
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% load bornhack %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>BackOffice - Manage Speaker+EventProposals</h2>
|
||||||
|
<div class="lead">
|
||||||
|
The Content team can approve or reject pending SpeakerProposals and EventProposals from this page.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<h3>SpeakerProposals</h3>
|
||||||
|
{% if not speakerproposals %}
|
||||||
|
<p class="lead">No pending SpeakerProposals found</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th class="text-center">Ticket?</th>
|
||||||
|
<th class="text-center">Speaker?</th>
|
||||||
|
<th>Submitting User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for proposal in speakerproposals %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ proposal.name }}</td>
|
||||||
|
<td>{{ proposal.email }}</td>
|
||||||
|
<td class="text-center">{{ proposal.needs_oneday_ticket|truefalseicon }}</td>
|
||||||
|
<td class="text-center">{{ proposal.event|truefalseicon }}</td>
|
||||||
|
<td>{{ proposal.user }}</td>
|
||||||
|
<td><a href="{% url 'backoffice:speakerproposal_manage' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-cog"></i> Manage</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>EventProposals</h3>
|
||||||
|
{% if not eventproposals %}
|
||||||
|
<p class="lead">No pending SpeakerProposals found</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Speakers</th>
|
||||||
|
<th class="text-center">Event?</th>
|
||||||
|
<th>Submitting User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for proposal in eventproposals %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ proposal.title }}</td>
|
||||||
|
<td>{{ proposal.track }}</td>
|
||||||
|
<td><i class="fas fa-{{ proposal.event_type.icon }} fa-lg" style="color: {{ proposal.event_type.color }};"></i> {{ proposal.event_type }}</td>
|
||||||
|
<td>{% for speaker in proposal.speakers.all %}<i class="fas fa-user" data-toggle="tooltip" title="{{ speaker.name }}"></i> {% endfor %}</td>
|
||||||
|
<td class="text-center">{{ proposal.speaker|truefalseicon }}</td>
|
||||||
|
<td>{{ proposal.user }}</td>
|
||||||
|
<td><a href="{% url 'backoffice:eventproposal_manage' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-cog"></i> Manage</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
|
|
15
src/backoffice/templates/manage_speakerproposal.html
Normal file
15
src/backoffice/templates/manage_speakerproposal.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Manage Speaker Proposal</h3>
|
||||||
|
{% include 'includes/speakerproposal_detail.html' with camp=camp %}
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-times'></i> Reject" button_type="submit" button_class="btn-danger" name="reject" %}
|
||||||
|
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
39
src/backoffice/templates/merchandise_to_order.html
Normal file
39
src/backoffice/templates/merchandise_to_order.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Merchandise To Order</h2>
|
||||||
|
<div class="lead">
|
||||||
|
This is a list of merchandise to order from our supplier
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
This table shows all different merchandise that needs to be ordered
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Merchandise Type</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, val in merchandise.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td>{{ val }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
{% endblock content %}
|
45
src/backoffice/templates/orders_merchandise.html
Normal file
45
src/backoffice/templates/orders_merchandise.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Merchandise Orders</h2>
|
||||||
|
<div class="lead">
|
||||||
|
Use this view to look at merchandise orders. </div>
|
||||||
|
<div>
|
||||||
|
This table shows all OrderProductRelations which are Merchandise (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).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>OPR Id</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for productrel in orderproductrelation_list %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/admin/shop/order/{{ productrel.order.id }}/change/">Order #{{ productrel.order.id }}</a></td>
|
||||||
|
<td>{{ productrel.order.user }}</td>
|
||||||
|
<td>{{ productrel.order.user.email }}</td>
|
||||||
|
<td>{{ productrel.id }}</td>
|
||||||
|
<td>{{ productrel.product.name }}</td>
|
||||||
|
<td>{{ productrel.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
45
src/backoffice/templates/orders_village.html
Normal file
45
src/backoffice/templates/orders_village.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Village Orders</h2>
|
||||||
|
<div class="lead">
|
||||||
|
Use this view to look at village orders.</div>
|
||||||
|
<div>
|
||||||
|
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).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>OPR Id</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for productrel in orderproductrelation_list %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/admin/shop/order/{{ productrel.order.id }}/change/">Order #{{ productrel.order.id }}</a></td>
|
||||||
|
<td>{{ productrel.order.user }}</td>
|
||||||
|
<td>{{ productrel.order.user.email }}</td>
|
||||||
|
<td>{{ productrel.id }}</td>
|
||||||
|
<td>{{ productrel.product.name }}</td>
|
||||||
|
<td>{{ productrel.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Hand Out Products</h2>
|
<h2>Hand Out Products</h2>
|
||||||
<div class="lead">
|
<div class="lead">
|
||||||
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 <a href="{% url 'backoffice:ticket_checkin' %}">Ticket Checkin view</a> instead. To hand out badges go to the <a href="{% url 'backoffice:badge_handout' %}">Badge Handout view</a> instead.
|
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 <a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}">Ticket Checkin view</a> instead. To hand out badges go to the <a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}">Badge Handout view</a> instead.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
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).
|
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).
|
||||||
|
@ -43,12 +43,5 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
$(document).ready(function(){
|
|
||||||
$('.table').DataTable();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
|
||||||
|
|
44
src/backoffice/templates/reimbursement_create.html
Normal file
44
src/backoffice/templates/reimbursement_create.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Create Reimbursement for User {{ reimbursement_user }}</h3>
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">The following approved expenses will be covered by this reimbursement:</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Invoice</th>
|
||||||
|
<th>Responsible Team</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for expense in expenses %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ expense.description }}</td>
|
||||||
|
<td>{{ expense.amount }}</td>
|
||||||
|
<td>{{ expense.invoice }}</td>
|
||||||
|
<td>{{ expense.responsible_team }} Team</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lead">The total amount for this reimbursement will be <b>{{ total_amount.amount__sum }} DKK</b></p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
|
||||||
|
<a href="{% url 'backoffice:reimbursement_create_userselect' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<h2>Create Reimbursement - Select User</h2>
|
||||||
|
<div class="lead">
|
||||||
|
Start by selecting the user for whom you wish to create a reimbursement below:
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="list-group">
|
||||||
|
{% for user in object_list %}
|
||||||
|
<a href="{% url 'backoffice:reimbursement_create' camp_slug=camp.slug user_id=user.id %}" class="list-group-item">
|
||||||
|
<h4 class="list-group-item-heading">{{ user.username }}</h4>
|
||||||
|
<p class="list-group-item-text">Create a reimbursement for user {{ user.username }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
17
src/backoffice/templates/reimbursement_delete.html
Normal file
17
src/backoffice/templates/reimbursement_delete.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Delete Reimbursement for User {{ reimbursement_user }}</h3>
|
||||||
|
|
||||||
|
<p>The total amount for this reimbursement is <b>{{ reimbursement.amount }} DKK</b></p>
|
||||||
|
|
||||||
|
<p class="lead">Really delete this reimbursement?</p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-times'></i> Yes, Delete it" button_type="submit" button_class="btn-danger" name="Delete" %}
|
||||||
|
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Reimbursement Details</h3>
|
||||||
|
|
||||||
|
{% include 'includes/reimbursement_detail_panel.html' %}
|
||||||
|
|
||||||
|
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_update" camp_slug=camp.slug pk=reimbursement.pk %}"><i class="fas fa-edit"></i> Update</a>
|
||||||
|
<a class="btn btn-danger" href="{% url "backoffice:reimbursement_delete" camp_slug=camp.slug pk=reimbursement.pk %}"><i class="fas fa-times"></i> Delete</a>
|
||||||
|
<a class="btn btn-primary" href="{% url "backoffice:reimbursement_list" camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back to reimbursement list</a>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
16
src/backoffice/templates/reimbursement_form.html
Normal file
16
src/backoffice/templates/reimbursement_form.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Update Reimbursement for User {{ reimbursement_user }}</h3>
|
||||||
|
|
||||||
|
<p class="lead">The total amount for this reimbursement is <b>{{ reimbursement.amount }} DKK</b></p>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Update" button_type="submit" button_class="btn-success" name="Update" %}
|
||||||
|
<a href="{% url 'backoffice:reimbursement_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
15
src/backoffice/templates/reimbursement_list_backoffice.html
Normal file
15
src/backoffice/templates/reimbursement_list_backoffice.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Reimbursements for {{ camp.title }}</h2>
|
||||||
|
|
||||||
|
{% include 'includes/reimbursement_list_panel.html' %}
|
||||||
|
|
||||||
|
<a class="btn btn-success" href="{% url "backoffice:reimbursement_create_userselect" camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create New Reimbursement</a>
|
||||||
|
{% endblock content %}
|
22
src/backoffice/templates/revenue_detail_backoffice.html
Normal file
22
src/backoffice/templates/revenue_detail_backoffice.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Manage Revenue</h3>
|
||||||
|
|
||||||
|
{% include 'includes/revenue_detail_panel.html' %}
|
||||||
|
|
||||||
|
{% if revenue.approved == None %}
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-check'></i> Approve Revenue" button_type="submit" button_class="btn-success" name="approve" %}
|
||||||
|
{% bootstrap_button "<i class='fas fa-times'></i> Reject Revenue" button_type="submit" button_class="btn-danger" name="reject" %}
|
||||||
|
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<br>
|
||||||
|
<a href="{% url 'backoffice:revenue_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Back to Revenue List</a>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
28
src/backoffice/templates/revenue_list_backoffice.html
Normal file
28
src/backoffice/templates/revenue_list_backoffice.html
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Manage Revenues for {{ camp.title }}</h2>
|
||||||
|
|
||||||
|
{% if unapproved_revenues %}
|
||||||
|
<div class="lead">
|
||||||
|
This table shows unapproved revenues for {{ camp.title }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'includes/revenue_list_panel.html' with revenue_list=unapproved_revenues %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="lead">
|
||||||
|
This table shows all approved revenues for {{ camp.title }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'includes/revenue_list_panel.html' %}
|
||||||
|
|
||||||
|
{% endblock content %}
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Ticket Check-In</h2>
|
<h2>Ticket Check-In</h2>
|
||||||
<div class="lead">
|
<div class="lead">
|
||||||
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 <a href="{% url 'backoffice:badge_handout' %}">Badge Handout view</a> instead. To hand out other products go to the <a href="{% url 'backoffice:product_handout' %}">Hand Out Products</a> view instead.
|
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 <a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}">Badge Handout view</a> instead. To hand out other products go to the <a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}">Hand Out Products</a> view instead.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
This table shows all (Shop|Discount|Sponsor)Tickets which are checked_in=False.
|
This table shows all (Shop|Discount|Sponsor)Tickets which are checked_in=False.
|
||||||
|
@ -47,12 +47,5 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
$(document).ready(function(){
|
|
||||||
$('.table').DataTable();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
|
||||||
|
|
38
src/backoffice/templates/village_to_order.html
Normal file
38
src/backoffice/templates/village_to_order.html
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<h2>Village Gear To Order</h2>
|
||||||
|
<div class="lead">
|
||||||
|
This is a list of village gear to order from our supplier
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
This table shows all different village stuff that needs to be ordered
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, val in village.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key }}</td>
|
||||||
|
<td>{{ val }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -1,10 +1,69 @@
|
||||||
from django.conf.urls import url
|
from django.urls import path, include
|
||||||
from .views import *
|
from .views import *
|
||||||
|
|
||||||
|
|
||||||
|
app_name = 'backoffice'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^$', BackofficeIndexView.as_view(), name='index'),
|
path('', BackofficeIndexView.as_view(), name='index'),
|
||||||
url(r'product_handout/$', ProductHandoutView.as_view(), name='product_handout'),
|
# infodesk
|
||||||
url(r'badge_handout/$', BadgeHandoutView.as_view(), name='badge_handout'),
|
path('product_handout/', ProductHandoutView.as_view(), name='product_handout'),
|
||||||
url(r'ticket_checkin/$', TicketCheckinView.as_view(), name='ticket_checkin'),
|
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/<uuid:pk>/', SpeakerProposalManageView.as_view(), name='speakerproposal_manage'),
|
||||||
|
path('events/<uuid:pk>/', EventProposalManageView.as_view(), name='eventproposal_manage'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# economy
|
||||||
|
path('economy/',
|
||||||
|
include([
|
||||||
|
# expenses
|
||||||
|
path('expenses/',
|
||||||
|
include([
|
||||||
|
path('', ExpenseListView.as_view(), name='expense_list'),
|
||||||
|
path('<uuid:pk>/', ExpenseDetailView.as_view(), name='expense_detail'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
# revenues
|
||||||
|
path('revenues/',
|
||||||
|
include([
|
||||||
|
path('', RevenueListView.as_view(), name='revenue_list'),
|
||||||
|
path('<uuid:pk>/', RevenueDetailView.as_view(), name='revenue_detail'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
# reimbursements
|
||||||
|
path('reimbursements/',
|
||||||
|
include([
|
||||||
|
path('', ReimbursementListView.as_view(), name='reimbursement_list'),
|
||||||
|
path('<uuid:pk>/',
|
||||||
|
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/<int:user_id>/', ReimbursementCreateView.as_view(), name='reimbursement_create'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -1,33 +1,52 @@
|
||||||
from django.views.generic import TemplateView, ListView
|
import logging, os
|
||||||
from django.shortcuts import redirect
|
from itertools import chain
|
||||||
from django.views import View
|
|
||||||
|
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.conf import settings
|
||||||
from django.utils.decorators import method_decorator
|
from django.core.files import File
|
||||||
from django.http import HttpResponseForbidden
|
|
||||||
|
from camps.mixins import CampViewMixin
|
||||||
from shop.models import OrderProductRelation
|
from shop.models import OrderProductRelation
|
||||||
from tickets.models import ShopTicket, SponsorTicket, DiscountTicket
|
from tickets.models import ShopTicket, SponsorTicket, DiscountTicket
|
||||||
from itertools import chain
|
from profiles.models import Profile
|
||||||
import logging
|
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__)
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
class StaffMemberRequiredMixin(object):
|
class BackofficeIndexView(CampViewMixin, RaisePermissionRequiredMixin, TemplateView):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
"""
|
||||||
if not request.user.is_staff:
|
The Backoffice index view only requires camps.backoffice_permission so we use RaisePermissionRequiredMixin directly
|
||||||
return HttpResponseForbidden()
|
"""
|
||||||
return super().dispatch(request, *args, **kwargs)
|
permission_required = ("camps.backoffice_permission")
|
||||||
|
template_name = "index.html"
|
||||||
|
|
||||||
|
|
||||||
class BackofficeIndexView(StaffMemberRequiredMixin, TemplateView):
|
class ProductHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
||||||
template_name = "backoffice_index.html"
|
|
||||||
|
|
||||||
|
|
||||||
class ProductHandoutView(StaffMemberRequiredMixin, ListView):
|
|
||||||
template_name = "product_handout.html"
|
template_name = "product_handout.html"
|
||||||
queryset = OrderProductRelation.objects.filter(handed_out=False, order__paid=True, order__refunded=False, order__cancelled=False).order_by('order')
|
|
||||||
|
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(StaffMemberRequiredMixin, ListView):
|
class BadgeHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
||||||
template_name = "badge_handout.html"
|
template_name = "badge_handout.html"
|
||||||
context_object_name = 'tickets'
|
context_object_name = 'tickets'
|
||||||
|
|
||||||
|
@ -38,7 +57,7 @@ class BadgeHandoutView(StaffMemberRequiredMixin, ListView):
|
||||||
return list(chain(shoptickets, sponsortickets, discounttickets))
|
return list(chain(shoptickets, sponsortickets, discounttickets))
|
||||||
|
|
||||||
|
|
||||||
class TicketCheckinView(StaffMemberRequiredMixin, ListView):
|
class TicketCheckinView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
||||||
template_name = "ticket_checkin.html"
|
template_name = "ticket_checkin.html"
|
||||||
context_object_name = 'tickets'
|
context_object_name = 'tickets'
|
||||||
|
|
||||||
|
@ -48,3 +67,381 @@ class TicketCheckinView(StaffMemberRequiredMixin, ListView):
|
||||||
discounttickets = DiscountTicket.objects.filter(checked_in=False)
|
discounttickets = DiscountTicket.objects.filter(checked_in=False)
|
||||||
return list(chain(shoptickets, sponsortickets, discounttickets))
|
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):
|
||||||
|
""" 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 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
|
||||||
|
|
||||||
|
|
||||||
|
################################
|
||||||
|
########### 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}))
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import ProductCategory, Product
|
from .models import ProductCategory, Product
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,5 +11,3 @@ class ProductCategoryAdmin(admin.ModelAdmin):
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'price', 'category', 'in_stock']
|
list_display = ['name', 'price', 'category', 'in_stock']
|
||||||
list_editable = ['in_stock']
|
list_editable = ['in_stock']
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
26
src/bar/migrations/0003_auto_20180318_0906.py
Normal file
26
src/bar/migrations/0003_auto_20180318_0906.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,11 +1,10 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from utils.models import CampRelatedModel
|
from utils.models import CampRelatedModel
|
||||||
|
|
||||||
|
|
||||||
class ProductCategory(CampRelatedModel):
|
class ProductCategory(CampRelatedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
camp = models.ForeignKey('camps.Camp')
|
camp = models.ForeignKey('camps.Camp', on_delete=models.PROTECT)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -17,7 +16,11 @@ class ProductCategory(CampRelatedModel):
|
||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
price = models.IntegerField()
|
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)
|
in_stock = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
from .models import ProductCategory, Product
|
from camps.mixins import CampViewMixin
|
||||||
|
from .models import ProductCategory
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
|
|
||||||
|
|
||||||
class MenuView(ListView):
|
class MenuView(CampViewMixin, ListView):
|
||||||
model = ProductCategory
|
model = ProductCategory
|
||||||
template_name = "bar_menu.html"
|
template_name = "bar_menu.html"
|
||||||
context_object_name = "categories"
|
context_object_name = "categories"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(camp=self.camp)
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
|
"""
|
||||||
|
ASGI entrypoint. Configures Django and then runs the application
|
||||||
|
defined in the ASGI_APPLICATION setting.
|
||||||
|
"""
|
||||||
import os
|
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")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bornhack.settings")
|
||||||
|
django.setup()
|
||||||
channel_layer = get_channel_layer()
|
application = get_default_application()
|
||||||
|
|
||||||
|
|
|
@ -20,14 +20,6 @@ DEBUG={{ django_debug }}
|
||||||
# the path to the wkhtmltopdf binary
|
# the path to the wkhtmltopdf binary
|
||||||
WKHTMLTOPDF_CMD="{{ wkhtmltopdf_path }}"
|
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
|
# start redirecting to the next camp instead of the previous camp after
|
||||||
# this much of the time between the camps has passed
|
# this much of the time between the camps has passed
|
||||||
|
@ -36,6 +28,9 @@ CAMP_REDIRECT_PERCENT=25
|
||||||
### changes below here are only needed for production
|
### changes below here are only needed for production
|
||||||
|
|
||||||
# email settings
|
# email settings
|
||||||
|
{% if not django_email_realworld | default(False) %}
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
{% endif %}
|
||||||
EMAIL_HOST='{{ django_email_host }}'
|
EMAIL_HOST='{{ django_email_host }}'
|
||||||
EMAIL_PORT={{ django_email_port }}
|
EMAIL_PORT={{ django_email_port }}
|
||||||
EMAIL_HOST_USER='{{ django_email_user }}'
|
EMAIL_HOST_USER='{{ django_email_user }}'
|
||||||
|
@ -76,13 +71,26 @@ SCHEDULE_EVENT_NOTIFICATION_MINUTES=10
|
||||||
# irc bot settings
|
# irc bot settings
|
||||||
IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10
|
IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10
|
||||||
IRCBOT_NICK='{{ django_ircbot_nickname }}'
|
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_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 <password>\x02."
|
||||||
IRCBOT_SERVER_HOSTNAME='{{ django_ircbot_server }}'
|
IRCBOT_SERVER_HOSTNAME='{{ django_ircbot_server }}'
|
||||||
IRCBOT_SERVER_PORT=6697
|
IRCBOT_SERVER_PORT=6697
|
||||||
IRCBOT_SERVER_USETLS=True
|
IRCBOT_SERVER_USETLS=True
|
||||||
IRCBOT_CHANNELS={
|
IRCBOT_PUBLIC_CHANNEL='{{ django_ircbot_public_channel }}'
|
||||||
'default': '{{ django_ircbot_default_channel }}',
|
IRCBOT_VOLUNTEER_CHANNEL='{{ django_ircbot_volunteer_channel }}'
|
||||||
'orga': '{{ django_ircbot_orga_channel }}',
|
|
||||||
'public': '{{ django_ircbot_public_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"
|
||||||
|
|
||||||
|
|
80
src/bornhack/environment_settings.py.dist.dev
Normal file
80
src/bornhack/environment_settings.py.dist.dev
Normal file
|
@ -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"
|
|
@ -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
|
from program.consumers import ScheduleConsumer
|
||||||
|
|
||||||
|
|
||||||
channel_routing = [
|
application = ProtocolTypeRouter({
|
||||||
ScheduleConsumer.as_route(path=r"^/schedule/"),
|
"websocket": AuthMiddlewareStack(
|
||||||
]
|
URLRouter([
|
||||||
|
url(r"^schedule/", ScheduleConsumer)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
48
src/bornhack/schema.py
Normal file
48
src/bornhack/schema.py
Normal file
|
@ -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)
|
|
@ -1,15 +1,31 @@
|
||||||
import os
|
import os
|
||||||
|
import wrapt
|
||||||
|
import django.views
|
||||||
|
|
||||||
from .environment_settings import *
|
from .environment_settings import *
|
||||||
|
|
||||||
|
|
||||||
def local_dir(entry):
|
def local_dir(entry):
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
os.path.dirname(os.path.dirname(__file__)),
|
os.path.dirname(os.path.dirname(__file__)),
|
||||||
entry
|
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__))
|
DJANGO_BASE_PATH = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
|
||||||
WSGI_APPLICATION = 'bornhack.wsgi.application'
|
WSGI_APPLICATION = 'bornhack.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'bornhack.routing.application'
|
||||||
ROOT_URLCONF = 'bornhack.urls'
|
ROOT_URLCONF = 'bornhack.urls'
|
||||||
|
|
||||||
ACCOUNT_ADAPTER = 'allauth_2fa.adapter.OTPAdapter'
|
ACCOUNT_ADAPTER = 'allauth_2fa.adapter.OTPAdapter'
|
||||||
|
@ -29,7 +45,9 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django.contrib.sites',
|
'django.contrib.sites',
|
||||||
|
|
||||||
|
'graphene_django',
|
||||||
'channels',
|
'channels',
|
||||||
|
'corsheaders',
|
||||||
|
|
||||||
'profiles',
|
'profiles',
|
||||||
'camps',
|
'camps',
|
||||||
|
@ -46,6 +64,11 @@ INSTALLED_APPS = [
|
||||||
'tickets',
|
'tickets',
|
||||||
'bar',
|
'bar',
|
||||||
'backoffice',
|
'backoffice',
|
||||||
|
'events',
|
||||||
|
'rideshare',
|
||||||
|
'tokens',
|
||||||
|
'feedback',
|
||||||
|
'economy',
|
||||||
|
|
||||||
'allauth',
|
'allauth',
|
||||||
'allauth.account',
|
'allauth.account',
|
||||||
|
@ -55,6 +78,8 @@ INSTALLED_APPS = [
|
||||||
'django_otp.plugins.otp_static',
|
'django_otp.plugins.otp_static',
|
||||||
'bootstrap3',
|
'bootstrap3',
|
||||||
'django_extensions',
|
'django_extensions',
|
||||||
|
'reversion',
|
||||||
|
'betterforms',
|
||||||
]
|
]
|
||||||
|
|
||||||
#MEDIA_URL = '/media/'
|
#MEDIA_URL = '/media/'
|
||||||
|
@ -109,6 +134,7 @@ BOOTSTRAP3 = {
|
||||||
'javascript_url': '/static/js/bootstrap.min.js'
|
'javascript_url': '/static/js/bootstrap.min.js'
|
||||||
}
|
}
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
@ -119,6 +145,9 @@ MIDDLEWARE = [
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CORS_ORIGIN_ALLOW_ALL = True
|
||||||
|
CORS_URLS_REGEX = r'^/api/*$'
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
INSTALLED_APPS += [
|
INSTALLED_APPS += [
|
||||||
|
@ -174,4 +203,6 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GRAPHENE = {
|
||||||
|
'SCHEMA': 'bornhack.schema.schema'
|
||||||
|
}
|
||||||
|
|
|
@ -1,322 +1,219 @@
|
||||||
from allauth.account.views import (
|
|
||||||
LoginView,
|
|
||||||
LogoutView,
|
|
||||||
)
|
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.urls import path
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
from bar.views import MenuView
|
||||||
from camps.views import *
|
from camps.views import *
|
||||||
|
from feedback.views import FeedbackCreate
|
||||||
from info.views import *
|
from info.views import *
|
||||||
from villages.views import *
|
from people.views import *
|
||||||
from program.views import *
|
from program.views import *
|
||||||
from sponsors.views import *
|
from sponsors.views import *
|
||||||
from teams.views import *
|
from villages.views import *
|
||||||
from people.views import *
|
|
||||||
from tickets.views import ShopTicketListView
|
|
||||||
from bar.views import MenuView
|
|
||||||
|
|
||||||
# require 2fa token entry (if enabled on admin account) when logging into /admin by using allauth login form
|
# require 2fa token entry (if enabled on admin account) when logging into /admin by using allauth login form
|
||||||
admin.site.login = login_required(admin.site.login)
|
admin.site.login = login_required(admin.site.login)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(
|
path(
|
||||||
r'^profile/',
|
'profile/',
|
||||||
include('profiles.urls', namespace='profiles')
|
include('profiles.urls', namespace='profiles')
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^tickets/',
|
'tickets/',
|
||||||
include('tickets.urls', namespace='tickets')
|
include('tickets.urls', namespace='tickets')
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^shop/',
|
'shop/',
|
||||||
include('shop.urls', namespace='shop')
|
include('shop.urls', namespace='shop')
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^news/',
|
'news/',
|
||||||
include('news.urls', namespace='news')
|
include('news.urls', namespace='news')
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^contact/',
|
'contact/',
|
||||||
TemplateView.as_view(template_name='contact.html'),
|
TemplateView.as_view(template_name='contact.html'),
|
||||||
name='contact'
|
name='contact'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^conduct/',
|
'conduct/',
|
||||||
TemplateView.as_view(template_name='coc.html'),
|
TemplateView.as_view(template_name='coc.html'),
|
||||||
name='conduct'
|
name='conduct'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^login/$',
|
'login/',
|
||||||
LoginView.as_view(),
|
LoginView.as_view(),
|
||||||
name='account_login',
|
name='account_login',
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^logout/$',
|
'logout/',
|
||||||
LogoutView.as_view(),
|
LogoutView.as_view(),
|
||||||
name='account_logout',
|
name='account_logout',
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^privacy-policy/$',
|
'privacy-policy/',
|
||||||
TemplateView.as_view(template_name='legal/privacy_policy.html'),
|
TemplateView.as_view(template_name='legal/privacy_policy.html'),
|
||||||
name='privacy-policy'
|
name='privacy-policy'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'^general-terms-and-conditions/$',
|
'general-terms-and-conditions/',
|
||||||
TemplateView.as_view(template_name='legal/general_terms_and_conditions.html'),
|
TemplateView.as_view(template_name='legal/general_terms_and_conditions.html'),
|
||||||
name='general-terms'
|
name='general-terms'
|
||||||
),
|
),
|
||||||
url(r'^accounts/', include('allauth.urls')),
|
path('accounts/', include('allauth.urls')),
|
||||||
url(r'^accounts/', include('allauth_2fa.urls')),
|
path('accounts/', include('allauth_2fa.urls')),
|
||||||
url(r'^admin/', include(admin.site.urls)),
|
path('admin/', include(admin.site.urls)),
|
||||||
|
|
||||||
url(
|
# We don't need CSRF checks for the API
|
||||||
r'^camps/$',
|
path('api/', csrf_exempt(GraphQLView.as_view(graphiql=True))),
|
||||||
|
|
||||||
|
path(
|
||||||
|
'camps/',
|
||||||
CampListView.as_view(),
|
CampListView.as_view(),
|
||||||
name='camp_list'
|
name='camp_list'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
path(
|
||||||
|
'token/',
|
||||||
|
include('tokens.urls', namespace='tokens'),
|
||||||
|
),
|
||||||
|
|
||||||
# camp redirect views here
|
# camp redirect views here
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^$',
|
'',
|
||||||
CampRedirectView.as_view(),
|
CampRedirectView.as_view(),
|
||||||
kwargs={'page': 'camp_detail'},
|
kwargs={'page': 'camp_detail'},
|
||||||
name='camp_detail_redirect',
|
name='camp_detail_redirect',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^program/$',
|
'program/',
|
||||||
CampRedirectView.as_view(),
|
CampRedirectView.as_view(),
|
||||||
kwargs={'page': 'schedule_index'},
|
kwargs={'page': 'schedule_index'},
|
||||||
name='schedule_index_redirect',
|
name='schedule_index_redirect',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^info/$',
|
'info/',
|
||||||
CampRedirectView.as_view(),
|
CampRedirectView.as_view(),
|
||||||
kwargs={'page': 'info'},
|
kwargs={'page': 'info'},
|
||||||
name='info_redirect',
|
name='info_redirect',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^sponsors/$',
|
'sponsors/',
|
||||||
CampRedirectView.as_view(),
|
CampRedirectView.as_view(),
|
||||||
kwargs={'page': 'sponsors'},
|
kwargs={'page': 'sponsors'},
|
||||||
name='sponsors_redirect',
|
name='sponsors_redirect',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^villages/$',
|
'villages/',
|
||||||
CampRedirectView.as_view(),
|
CampRedirectView.as_view(),
|
||||||
kwargs={'page': 'village_list'},
|
kwargs={'page': 'village_list'},
|
||||||
name='village_list_redirect',
|
name='village_list_redirect',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^people/$',
|
'people/',
|
||||||
PeopleView.as_view(),
|
PeopleView.as_view(),
|
||||||
name='people',
|
name='people',
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
|
||||||
r'^backoffice/',
|
|
||||||
include('backoffice.urls', namespace='backoffice')
|
|
||||||
),
|
|
||||||
|
|
||||||
# camp specific urls below here
|
# camp specific urls below here
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'(?P<camp_slug>[-_\w+]+)/', include([
|
'<slug:camp_slug>/', include([
|
||||||
url(
|
path(
|
||||||
r'^$',
|
'',
|
||||||
CampDetailView.as_view(),
|
CampDetailView.as_view(),
|
||||||
name='camp_detail'
|
name='camp_detail'
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^info/$',
|
'info/',
|
||||||
CampInfoView.as_view(),
|
CampInfoView.as_view(),
|
||||||
name='info'
|
name='info'
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^program/', include([
|
'program/',
|
||||||
url(
|
include('program.urls', namespace='program'),
|
||||||
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<pk>[a-f0-9-]+)/$',
|
|
||||||
SpeakerProposalDetailView.as_view(),
|
|
||||||
name='speakerproposal_detail'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[a-f0-9-]+)/edit/$',
|
|
||||||
SpeakerProposalUpdateView.as_view(),
|
|
||||||
name='speakerproposal_update'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[a-f0-9-]+)/submit/$',
|
|
||||||
SpeakerProposalSubmitView.as_view(),
|
|
||||||
name='speakerproposal_submit'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[a-f0-9-]+)/pictures/(?P<picture>[-_\w+]+)/$',
|
|
||||||
SpeakerProposalPictureView.as_view(),
|
|
||||||
name='speakerproposal_picture',
|
|
||||||
),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^events/', include([
|
|
||||||
url(
|
|
||||||
r'^create/$',
|
|
||||||
EventProposalCreateView.as_view(),
|
|
||||||
name='eventproposal_create'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[a-f0-9-]+)/$',
|
|
||||||
EventProposalDetailView.as_view(),
|
|
||||||
name='eventproposal_detail'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[a-f0-9-]+)/edit/$',
|
|
||||||
EventProposalUpdateView.as_view(),
|
|
||||||
name='eventproposal_update'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<pk>[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<slug>[-_\w+]+)/$',
|
|
||||||
SpeakerDetailView.as_view(),
|
|
||||||
name='speaker_detail'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^(?P<slug>[-_\w+]+)/pictures/(?P<picture>[-_\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<slug>[-_\w+]+)/$',
|
|
||||||
EventDetailView.as_view(),
|
|
||||||
name='event_detail'
|
|
||||||
),
|
|
||||||
])
|
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^sponsors/call/$',
|
'sponsors/',
|
||||||
CallForSponsorsView.as_view(),
|
|
||||||
name='call-for-sponsors'
|
|
||||||
),
|
|
||||||
url(
|
|
||||||
r'^sponsors/$',
|
|
||||||
SponsorsView.as_view(),
|
SponsorsView.as_view(),
|
||||||
name='sponsors'
|
name='sponsors'
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^bar/menu$',
|
'bar/menu/',
|
||||||
MenuView.as_view(),
|
MenuView.as_view(),
|
||||||
name='menu'
|
name='menu'
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^villages/', include([
|
'villages/', include([
|
||||||
url(
|
path(
|
||||||
r'^$',
|
'',
|
||||||
VillageListView.as_view(),
|
VillageListView.as_view(),
|
||||||
name='village_list'
|
name='village_list'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'create/$',
|
'create/',
|
||||||
VillageCreateView.as_view(),
|
VillageCreateView.as_view(),
|
||||||
name='village_create'
|
name='village_create'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'(?P<slug>[-_\w+]+)/delete/$',
|
'<slug:slug>/delete/',
|
||||||
VillageDeleteView.as_view(),
|
VillageDeleteView.as_view(),
|
||||||
name='village_delete'
|
name='village_delete'
|
||||||
),
|
),
|
||||||
url(
|
path(
|
||||||
r'(?P<slug>[-_\w+]+)/edit/$',
|
'<slug:slug>/edit/',
|
||||||
VillageUpdateView.as_view(),
|
VillageUpdateView.as_view(),
|
||||||
name='village_update'
|
name='village_update'
|
||||||
),
|
),
|
||||||
# this has to be the last url in the list
|
# this has to be the last url in the list
|
||||||
url(
|
path(
|
||||||
r'(?P<slug>[-_\w+]+)/$',
|
'<slug:slug>/',
|
||||||
VillageDetailView.as_view(),
|
VillageDetailView.as_view(),
|
||||||
name='village_detail'
|
name='village_detail'
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
|
||||||
url(
|
path(
|
||||||
r'^teams/',
|
'teams/',
|
||||||
include('teams.urls', namespace='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'),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
@ -324,5 +221,6 @@ urlpatterns = [
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^__debug__/', include(debug_toolbar.urls)),
|
path('__debug__/', include(debug_toolbar.urls)),
|
||||||
] + urlpatterns
|
] + urlpatterns
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
from django.conf import settings
|
|
||||||
from .models import Camp
|
from .models import Camp
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
def camp(request):
|
def camp(request):
|
||||||
|
|
|
@ -30,7 +30,7 @@ class Command(BaseCommand):
|
||||||
files = [
|
files = [
|
||||||
'sponsors/templates/{camp_slug}_sponsors.html',
|
'sponsors/templates/{camp_slug}_sponsors.html',
|
||||||
'camps/templates/{camp_slug}_camp_detail.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
|
# 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)
|
'static_src/img/{camp_slug}/logo/{camp_slug}-logo-small.png'.format(camp_slug=camp_slug)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class Migration(migrations.Migration):
|
||||||
('updated', models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)),
|
('uuid', models.UUIDField(default=uuid.uuid4, serialize=False, editable=False, primary_key=True)),
|
||||||
('date', models.DateField(help_text='What date?', verbose_name='Date')),
|
('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={
|
options={
|
||||||
'verbose_name_plural': 'Days',
|
'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')),
|
('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)),
|
('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')),
|
('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')),
|
('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(to=settings.AUTH_USER_MODEL, blank=True, help_text='Which user, if any, covered this expense.', verbose_name='Covered by', null=True)),
|
('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={
|
options={
|
||||||
'verbose_name_plural': 'Expenses',
|
'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)),
|
('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)),
|
('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)),
|
('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')),
|
('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(to=settings.AUTH_USER_MODEL, help_text='The user that has signed up.', verbose_name='User')),
|
('user', models.ForeignKey(on_delete=models.PROTECT, to=settings.AUTH_USER_MODEL, help_text='The user that has signed up.', verbose_name='User')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name_plural': 'Signups',
|
'verbose_name_plural': 'Signups',
|
||||||
|
|
20
src/camps/migrations/0023_camp_shortslug.py
Normal file
20
src/camps/migrations/0023_camp_shortslug.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
22
src/camps/migrations/0024_populate_camp_shortslugs.py
Normal file
22
src/camps/migrations/0024_populate_camp_shortslugs.py
Normal file
|
@ -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),
|
||||||
|
]
|
||||||
|
|
20
src/camps/migrations/0025_auto_20180318_1250.py
Normal file
20
src/camps/migrations/0025_auto_20180318_1250.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
23
src/camps/migrations/0026_auto_20180506_1633.py
Normal file
23
src/camps/migrations/0026_auto_20180506_1633.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
23
src/camps/migrations/0027_auto_20180525_1019.py
Normal file
23
src/camps/migrations/0027_auto_20180525_1019.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
23
src/camps/migrations/0028_auto_20180525_1025.py
Normal file
23
src/camps/migrations/0028_auto_20180525_1025.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
17
src/camps/migrations/0029_auto_20180815_2018.py
Normal file
17
src/camps/migrations/0029_auto_20180815_2018.py
Normal file
|
@ -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'},
|
||||||
|
),
|
||||||
|
]
|
18
src/camps/migrations/0030_camp_light_text.py
Normal file
18
src/camps/migrations/0030_camp_light_text.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
28
src/camps/migrations/0031_auto_20180830_0014.py
Normal file
28
src/camps/migrations/0031_auto_20180830_0014.py
Normal file
|
@ -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'},
|
||||||
|
),
|
||||||
|
]
|
17
src/camps/migrations/0032_auto_20180917_1754.py
Normal file
17
src/camps/migrations/0032_auto_20180917_1754.py
Normal file
|
@ -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'))},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,6 @@
|
||||||
from camps.models import Camp
|
from camps.models import Camp
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
|
||||||
class CampViewMixin(object):
|
class CampViewMixin(object):
|
||||||
|
@ -8,19 +9,49 @@ class CampViewMixin(object):
|
||||||
It also filters out objects that belong to other camps when the queryset has
|
It also filters out objects that belong to other camps when the queryset has
|
||||||
a direct relation to the Camp model.
|
a direct relation to the Camp model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
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)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super(CampViewMixin, self).get_queryset()
|
queryset = super().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)
|
|
||||||
|
|
||||||
# Camp relation not found, or queryset is empty, return it unaltered
|
# if this queryset is empty return it right away, because nothing for us to do
|
||||||
|
if not queryset:
|
||||||
return 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
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,24 @@ import logging
|
||||||
logger = logging.getLogger("bornhack.%s" % __name__)
|
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 Camp(CreatedUpdatedModel, UUIDModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Camp'
|
verbose_name = 'Camp'
|
||||||
|
@ -34,6 +52,11 @@ class Camp(CreatedUpdatedModel, UUIDModel):
|
||||||
help_text='The url slug to use for this camp'
|
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(
|
buildup = DateTimeRangeField(
|
||||||
verbose_name='Buildup Period',
|
verbose_name='Buildup Period',
|
||||||
help_text='The camp buildup period.',
|
help_text='The camp buildup period.',
|
||||||
|
@ -60,6 +83,33 @@ class Camp(CreatedUpdatedModel, UUIDModel):
|
||||||
max_length=7
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse('camp_detail', kwargs={'camp_slug': self.slug})
|
return reverse('camp_detail', kwargs={'camp_slug': self.slug})
|
||||||
|
|
||||||
|
@ -86,7 +136,7 @@ class Camp(CreatedUpdatedModel, UUIDModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_types(self):
|
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()
|
return EventType.objects.filter(event__instances__isnull=False, event__camp=self).distinct()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -174,17 +224,3 @@ class Camp(CreatedUpdatedModel, UUIDModel):
|
||||||
'''
|
'''
|
||||||
return self.get_days('teardown')
|
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
|
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9 col-sm-9 text-container">
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'call_for_speakers' camp_slug=camp.slug %}">call for speakers</a>.</div>
|
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'program:call_for_participation' camp_slug=camp.slug %}">call for participation</a>.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9 col-sm-9 text-container">
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'call_for_speakers' camp_slug=camp.slug %}">call for speakers</a>.</div>
|
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'program:call_for_participation' camp_slug=camp.slug %}">call for participation</a>.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9 col-sm-9 text-container">
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
<div class="lead">
|
<div class="lead">
|
||||||
<strong>Bornhack 2019</strong> will be the third BornHack. It will take place from <strong>August 16th to August 23rd 2019</strong> on the Danish island of Bornholm.
|
<strong>Bornhack 2019</strong> will be the fourth BornHack. It will take place from <strong>Thursday the 8th of August to Thursday the 15th of August 2019</strong> at a our new venue on the Danish island of Funen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9 col-sm-9 text-container">
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'call_for_speakers' camp_slug=camp.slug %}">call for speakers</a>.</div>
|
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'program:call_for_participation' camp_slug=camp.slug %}">call for participation</a>.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
92
src/camps/templates/bornhack-2020_camp_detail.html
Normal file
92
src/camps/templates/bornhack-2020_camp_detail.html
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load commonmark %}
|
||||||
|
{% load static from staticfiles %}
|
||||||
|
{% load imageutils %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<img src="{% static camp.logo_large %}" height="350px" width="400px" class="img-responsive" id="front-logo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
|
<div class="lead">
|
||||||
|
<b>BornHack</b> 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 <b>have fun</b>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3">
|
||||||
|
{% thumbnail 'img/bornhack-2016/esbjerg' '1600x988-B12A2610.jpg' 'The family area at BornHack 2016' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 col-sm-3">
|
||||||
|
{% thumbnail 'img/bornhack-2016/esbjerg' '1600x1000-B12A2398.jpg' 'A random hackers laptop' %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
|
<div class="lead">
|
||||||
|
<strong>Bornhack 2020</strong> will be the fifth BornHack. It will take place from <strong>August 11th to August 18th 2020</strong> on the Danish island of Bornholm.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
|
<div class="lead">
|
||||||
|
The BornHack team looks forward to organising another great event for the hacker community. We <a href="{% url 'teams:list' camp_slug=camp.slug %}">still need volunteers</a>, so please let us know if you want to help!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
{% thumbnail 'img/bornhack-2016/esbjerg' '1600x988-B12A2631.jpg' 'The BornHack 2016 organiser team' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 col-sm-3">
|
||||||
|
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5149.JPG' 'Danish politicians debating at BornHack 2016' %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
|
<div class="lead">We want to encourage <strong>hackers, makers, politicians, activists, developers, artists, sysadmins, engineers</strong> with something to say to read our <a href="{% url 'program:call_for_participation' camp_slug=camp.slug %}">call for participation</a>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9 col-sm-9 text-container">
|
||||||
|
<div class="lead">
|
||||||
|
BornHack aims to <strong>keep ticket prices affordable</strong> for everyone and to that end <strong>we need sponsors</strong>. Please see our <a href="{% url 'sponsors' camp_slug=camp.slug %}">call for sponsors</a> if you want to sponsor us, or if you work for a company you think might be able to help.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3">
|
||||||
|
{% thumbnail 'img/bornhack-2016/fonsmark' 'FB1_5265.JPG' 'Organisers thanking the BornHack 2016 sponsors' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 col-sm-12">
|
||||||
|
<p class="lead">You are very welcome to ask questions and show your interest on our different channels:</p>
|
||||||
|
{% include 'includes/contact.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p align="center">
|
||||||
|
{% 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' %}
|
||||||
|
</p>
|
||||||
|
{% endblock content %}
|
|
@ -24,7 +24,7 @@
|
||||||
<td>{{ listcamp.buildup.lower }} to {{ listcamp.buildup.upper }}</td>
|
<td>{{ listcamp.buildup.lower }} to {{ listcamp.buildup.upper }}</td>
|
||||||
<td>{{ listcamp.camp.lower }} to {{ listcamp.camp.upper }}</td>
|
<td>{{ listcamp.camp.lower }} to {{ listcamp.camp.upper }}</td>
|
||||||
<td>{{ listcamp.teardown.lower }} to {{ listcamp.teardown.upper }}</td>
|
<td>{{ listcamp.teardown.lower }} to {{ listcamp.teardown.upper }}</td>
|
||||||
<td><font color="{{ listcamp.colour }}">{{ listcamp.colour }}</font></td>
|
<td style="background: {{ listcamp.colour }}">{% if listcamp.light_text %}<font color="#ffffff">{% else %}<font color="#000000">{% endif %}{{ listcamp.colour }}</font></td>
|
||||||
</tr>
|
</tr>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from camps.models import Camp
|
from camps.models import Camp
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
|
||||||
def get_current_camp():
|
def get_current_camp():
|
||||||
try:
|
try:
|
||||||
|
@ -7,3 +9,40 @@ def get_current_camp():
|
||||||
except Camp.DoesNotExist:
|
except Camp.DoesNotExist:
|
||||||
return False
|
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
|
||||||
|
|
|
@ -18,19 +18,29 @@ class CampRedirectView(CampViewMixin, View):
|
||||||
camp = Camp.objects.get(
|
camp = Camp.objects.get(
|
||||||
camp__contains=now
|
camp__contains=now
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Redirecting to camp '%s' for page '%s' because it is now!" % (camp.slug, kwargs['page']))
|
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:
|
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(
|
prevcamp = Camp.objects.filter(
|
||||||
camp__endswith__lt=now
|
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(
|
nextcamp = Camp.objects.filter(
|
||||||
camp__startswith__gt=now
|
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
|
# find the number of days between the two camps
|
||||||
daysbetween = (nextcamp.camp.lower - prevcamp.camp.upper).days
|
daysbetween = (nextcamp.camp.lower - prevcamp.camp.upper).days
|
||||||
|
|
||||||
|
@ -40,14 +50,15 @@ class CampRedirectView(CampViewMixin, View):
|
||||||
# find the percentage of time passed
|
# find the percentage of time passed
|
||||||
percentpassed = (dayssinceprevcamp / daysbetween) * 100
|
percentpassed = (dayssinceprevcamp / daysbetween) * 100
|
||||||
|
|
||||||
# do the redirect
|
# figure out where to redirect
|
||||||
if percentpassed > settings.CAMP_REDIRECT_PERCENT:
|
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
|
camp = nextcamp
|
||||||
else:
|
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
|
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)))
|
# do the redirect
|
||||||
|
|
||||||
return redirect(kwargs['page'], camp_slug=camp.slug)
|
return redirect(kwargs['page'], camp_slug=camp.slug)
|
||||||
|
|
||||||
|
|
||||||
|
|
0
src/economy/__init__.py
Normal file
0
src/economy/__init__.py
Normal file
58
src/economy/admin.py
Normal file
58
src/economy/admin.py
Normal file
|
@ -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']
|
||||||
|
|
5
src/economy/apps.py
Normal file
5
src/economy/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EconomyConfig(AppConfig):
|
||||||
|
name = 'economy'
|
86
src/economy/email.py
Normal file
86
src/economy/email.py
Normal file
|
@ -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],
|
||||||
|
)
|
||||||
|
|
58
src/economy/forms.py
Normal file
58
src/economy/forms.py
Normal file
|
@ -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']
|
||||||
|
|
69
src/economy/migrations/0001_initial.py
Normal file
69
src/economy/migrations/0001_initial.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
40
src/economy/migrations/0002_revenue.py
Normal file
40
src/economy/migrations/0002_revenue.py
Normal file
|
@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
19
src/economy/migrations/0003_auto_20180917_1933.py
Normal file
19
src/economy/migrations/0003_auto_20180917_1933.py
Normal file
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
20
src/economy/migrations/0004_auto_20181120_1835.py
Normal file
20
src/economy/migrations/0004_auto_20181120_1835.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
23
src/economy/migrations/0005_auto_20190120_1532.py
Normal file
23
src/economy/migrations/0005_auto_20190120_1532.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
23
src/economy/migrations/0006_auto_20190120_1642.py
Normal file
23
src/economy/migrations/0006_auto_20190120_1642.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
0
src/economy/migrations/__init__.py
Normal file
0
src/economy/migrations/__init__.py
Normal file
41
src/economy/mixins.py
Normal file
41
src/economy/mixins.py
Normal file
|
@ -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()
|
||||||
|
|
310
src/economy/models.py
Normal file
310
src/economy/models.py
Normal file
|
@ -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. Format is YYYY-MM-DD.',
|
||||||
|
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. Format is YYYY-MM-DD.',
|
||||||
|
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
|
||||||
|
|
||||||
|
|
45
src/economy/templates/dashboard.html
Normal file
45
src/economy/templates/dashboard.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Economy | {{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<{% block content %}
|
||||||
|
<h3>Your {{ camp.title }} Economy Overview</h3>
|
||||||
|
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>What</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><h4>Expenses</h4></td>
|
||||||
|
<td>You have <b>{{ expense_count }} expense{{ expense_count|pluralize }}</b> ({{ approved_expense_count }} approved, {{ rejected_expense_count }} rejected, and {{ unapproved_expense_count }} pending approval) for {{ camp.title }}, for a total of <b>{{ expense_total|default:"0" }} DKK</b>.</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "economy:expense_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Expenses</a>
|
||||||
|
<a href="{% url "economy:expense_create" camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create Expense</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><h4>Reimbursements</h4></td>
|
||||||
|
<td>You have <b>{{ reimbursement_count }} reimbursement{{ reimbursement_count|pluralize }}</b> ({{ paid_reimbursement_count }} paid, {{ unpaid_reimbursement_count }} pending payment) for {{ camp.title }}, for a total of <b>{{ reimbursement_total }} DKK</b>.</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "economy:reimbursement_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Reimbursements</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><h4>Revenue</h4></td>
|
||||||
|
<td>You have <b>{{ revenue_count }} revenue{{ revenue_count|pluralize }}</b> ({{ approved_revenue_count }} approved, {{ rejected_revenue_count }} rejected, and {{ unapproved_revenue_count }} still pending approval) for {{ camp.title }}, for a total of <b>{{ revenue_total|default:"0" }} DKK</b>.</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "economy:revenue_list" camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-list"></i> List Revenues</a>
|
||||||
|
<a href="{% url "economy:revenue_create" camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create Revenue</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
12
src/economy/templates/emails/expense_approved_email.txt
Normal file
12
src/economy/templates/emails/expense_approved_email.txt
Normal file
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
12
src/economy/templates/emails/expense_rejected_email.txt
Normal file
12
src/economy/templates/emails/expense_rejected_email.txt
Normal file
|
@ -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
|
||||||
|
|
10
src/economy/templates/emails/revenue_approved_email.txt
Normal file
10
src/economy/templates/emails/revenue_approved_email.txt
Normal file
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
12
src/economy/templates/emails/revenue_rejected_email.txt
Normal file
12
src/economy/templates/emails/revenue_rejected_email.txt
Normal file
|
@ -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
|
||||||
|
|
16
src/economy/templates/expense_delete.html
Normal file
16
src/economy/templates/expense_delete.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Delete Expense | {{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>Really delete expense {{ expense.uuid }}?</h3>
|
||||||
|
{% include 'includes/expense_detail_panel.html' %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-danger" type="submit"><i class="fas fa-times"></i> Delete Expense</button>
|
||||||
|
<a href="{% url 'economy:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
12
src/economy/templates/expense_detail.html
Normal file
12
src/economy/templates/expense_detail.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Expense Details | {{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
{% include 'includes/expense_detail_panel.html' %}
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary" href="{% url 'economy:expense_list' camp_slug=camp.slug %}">Back to Expense List</a>
|
||||||
|
{% endblock %}
|
16
src/economy/templates/expense_form.html
Normal file
16
src/economy/templates/expense_form.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% if object %}Update{% else %}Create{% endif %} Expense | {{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>{% if object %}Update{% else %}Create{% endif %} {{ camp.title }} Expense</h3>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<button class="btn btn-success" type="submit"><i class="fas fa-plus"></i> Save</button>
|
||||||
|
<a href="{% url 'economy:expense_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
24
src/economy/templates/expense_list.html
Normal file
24
src/economy/templates/expense_list.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Expenses | {{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
|
||||||
|
{% endblock extra_head %}
|
||||||
|
|
||||||
|
<{% block content %}
|
||||||
|
<h3>Your {{ camp.title }} Expenses</h3>
|
||||||
|
|
||||||
|
{% include 'includes/expense_list_panel.html' %}
|
||||||
|
|
||||||
|
{% if perms.camps.expense_create_permission %}
|
||||||
|
<a class="btn btn-primary" href="{% url 'economy:expense_create' camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create Expense</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-danger"><p class="lead"><span class="text-error">You don't have permission to add expenses. Please ask someone from the Economy team to add the permission if you need it.</p></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue