Merge branch 'master' into 2-factor-auth

This commit is contained in:
Víðir Valberg Guðmundsson 2019-03-26 09:46:04 +01:00
commit 563bd8a59d
476 changed files with 18827 additions and 6210 deletions

29
LICENSE Normal file
View 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

View file

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

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

View file

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

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
from django.apps import AppConfig from django.apps import AppConfig

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

86
src/economy/email.py Normal file
View 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
View 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']

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

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

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

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

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

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

View file

41
src/economy/mixins.py Normal file
View 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
View 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

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

View file

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

View file

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

View 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

View file

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

View 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

View 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

View file

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

View 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

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

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

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

View 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