diff --git a/.bashrc b/.bashrc new file mode 100644 index 0000000..654e266 --- /dev/null +++ b/.bashrc @@ -0,0 +1,925 @@ +# =============================================================== # +# +# PERSONAL $HOME/.bashrc FILE for bash-3.0 (or later) +# By Emmanuel Rouat [no-email] +# +# Last modified: Tue Nov 20 22:04:47 CET 2012 + +# This file is normally read by interactive shells only. +#+ Here is the place to define your aliases, functions and +#+ other interactive features like your prompt. +# +# The majority of the code here assumes you are on a GNU +#+ system (most likely a Linux box) and is often based on code +#+ found on Usenet or Internet. +# +# See for instance: +# http://tldp.org/LDP/abs/html/index.html +# http://www.caliban.org/bash +# http://www.shelldorado.com/scripts/categories.html +# http://www.dotfiles.org +# +# The choice of colors was done for a shell with a dark background +#+ (white on black), and this is usually also suited for pure text-mode +#+ consoles (no X server available). If you use a white background, +#+ you'll have to do some other choices for readability. +# +# This bashrc file is a bit overcrowded. +# Remember, it is just just an example. +# Tailor it to your needs. +# +# =============================================================== # + +# --> Comments added by HOWTO author. + +# If not running interactively, don't do anything +[ -z "$PS1" ] && return + + +#------------------------------------------------------------- +# Source global definitions (if any) +#------------------------------------------------------------- + + +if [ -f /etc/bashrc ]; then + . /etc/bashrc # --> Read /etc/bashrc, if present. +fi + + +#-------------------------------------------------------------- +# Automatic setting of $DISPLAY (if not set already). +# This works for me - your mileage may vary. . . . +# The problem is that different types of terminals give +#+ different answers to 'who am i' (rxvt in particular can be +#+ troublesome) - however this code seems to work in a majority +#+ of cases. +#-------------------------------------------------------------- + +function get_xserver () +{ + case $TERM in + xterm ) + XSERVER=$(who am i | awk '{print $NF}' | tr -d ')''(' ) + # Ane-Pieter Wieringa suggests the following alternative: + # I_AM=$(who am i) + # SERVER=${I_AM#*(} + # SERVER=${SERVER%*)} + XSERVER=${XSERVER%%:*} + ;; + aterm | rxvt) + # Find some code that works here. ... + ;; + esac +} + +if [ -z ${DISPLAY:=""} ]; then + # get_xserver + if [[ -z ${XSERVER} || ${XSERVER} == $(hostname) || + ${XSERVER} == "unix" ]]; then + DISPLAY=":0.0" # Display on local host. + else + DISPLAY=${XSERVER}:0.0 # Display on remote host. + fi +fi + +export DISPLAY + +#------------------------------------------------------------- +# Some settings +#------------------------------------------------------------- + +#set -o nounset # These two options are useful for debugging. +#set -o xtrace +alias debug="set -o nounset; set -o xtrace" + +ulimit -S -c 0 # Don't want coredumps. +set -o notify +set -o noclobber +# set -o ignoreeof + + +# Enable options: +shopt -s cdspell +shopt -s cdable_vars +shopt -s checkhash +shopt -s checkwinsize +shopt -s sourcepath +shopt -s no_empty_cmd_completion +shopt -s cmdhist +shopt -s histappend histreedit histverify +shopt -s extglob # Necessary for programmable completion. + +# Disable options: +shopt -u mailwarn +unset MAILCHECK # Don't want my shell to warn me of incoming mail. + + +#------------------------------------------------------------- +# Greeting, motd etc. ... +#------------------------------------------------------------- + +# Color definitions (taken from Color Bash Prompt HowTo). +# Some colors might look different of some terminals. +# For example, I see 'Bold Red' as 'orange' on my screen, +# hence the 'Green' 'BRed' 'Red' sequence I often use in my prompt. + + +# Normal Colors +Black='\e[0;30m' # Black +Red='\e[0;31m' # Red +Green='\e[0;32m' # Green +Yellow='\e[0;33m' # Yellow +Blue='\e[0;34m' # Blue +Purple='\e[0;35m' # Purple +Cyan='\e[0;36m' # Cyan +White='\e[0;37m' # White + +# Bold +BBlack='\e[1;30m' # Black +BRed='\e[1;31m' # Red +BGreen='\e[1;32m' # Green +BYellow='\e[1;33m' # Yellow +BBlue='\e[1;34m' # Blue +BPurple='\e[1;35m' # Purple +BCyan='\e[1;36m' # Cyan +BWhite='\e[1;37m' # White + +# Background +On_Black='\e[40m' # Black +On_Red='\e[41m' # Red +On_Green='\e[42m' # Green +On_Yellow='\e[43m' # Yellow +On_Blue='\e[44m' # Blue +On_Purple='\e[45m' # Purple +On_Cyan='\e[46m' # Cyan +On_White='\e[47m' # White + +NC="\e[m" # Color Reset + + +ALERT=${BWhite}${On_Red} # Bold White on red background + + + +echo -e "${BCyan}This is BASH ${BRed}${BASH_VERSION%.*}${BCyan}\ +- DISPLAY on ${BRed}$DISPLAY${NC}\n" +date +if [ -x /usr/games/fortune ]; then + /usr/games/fortune -s # Makes our day a bit more fun.... :-) +fi + +# function _exit() # Function to run upon exit of shell. +# { +# echo -e "${BRed}Hasta la vista, baby${NC}" +# } +# trap _exit EXIT + +#------------------------------------------------------------- +# Shell Prompt - for many examples, see: +# http://www.debian-administration.org/articles/205 +# http://www.askapache.com/linux/bash-power-prompt.html +# http://tldp.org/HOWTO/Bash-Prompt-HOWTO +# https://github.com/nojhan/liquidprompt +#------------------------------------------------------------- +# Current Format: [TIME USER@HOST PWD] > +# TIME: +# Green == machine load is low +# Orange == machine load is medium +# Red == machine load is high +# ALERT == machine load is very high +# USER: +# Cyan == normal user +# Orange == SU to user +# Red == root +# HOST: +# Cyan == local session +# Green == secured remote connection (via ssh) +# Red == unsecured remote connection +# PWD: +# Green == more than 10% free disk space +# Orange == less than 10% free disk space +# ALERT == less than 5% free disk space +# Red == current user does not have write privileges +# Cyan == current filesystem is size zero (like /proc) +# >: +# White == no background or suspended jobs in this shell +# Cyan == at least one background job in this shell +# Orange == at least one suspended job in this shell +# +# Command is added to the history file each time you hit enter, +# so it's available to all shells (using 'history -a'). + + +# Test connection type: +if [ -n "${SSH_CONNECTION}" ]; then + CNX=${Green} # Connected on remote machine, via ssh (good). +elif [[ "${DISPLAY%%:0*}" != "" ]]; then + CNX=${ALERT} # Connected on remote machine, not via ssh (bad). +else + CNX=${BCyan} # Connected on local machine. +fi + +# Test user type: +if [[ ${USER} == "root" ]]; then + SU=${Red} # User is root. +# elif [[ ${USER} != $(logname) ]]; then +# SU=${BRed} # User is not login user. +else + SU=${BCyan} # User is normal (well ... most of us are). +fi + + + +NCPU=$(grep -c 'processor' /proc/cpuinfo) # Number of CPUs +SLOAD=$(( 100*${NCPU} )) # Small load +MLOAD=$(( 200*${NCPU} )) # Medium load +XLOAD=$(( 400*${NCPU} )) # Xlarge load + +# Returns system load as percentage, i.e., '40' rather than '0.40)'. +function load() +{ + local SYSLOAD=$(cut -d " " -f1 /proc/loadavg | tr -d '.') + # System load of the current host. + echo $((10#$SYSLOAD)) # Convert to decimal. +} + +# Returns a color indicating system load. +function load_color() +{ + local SYSLOAD=$(load) + if [ ${SYSLOAD} -gt ${XLOAD} ]; then + echo -en ${ALERT} + elif [ ${SYSLOAD} -gt ${MLOAD} ]; then + echo -en ${Red} + elif [ ${SYSLOAD} -gt ${SLOAD} ]; then + echo -en ${BRed} + else + echo -en ${Green} + fi +} + +# Returns a color according to free disk space in $PWD. +function disk_color() +{ + if [ ! -w "${PWD}" ] ; then + echo -en ${Red} + # No 'write' privilege in the current directory. + elif [ -s "${PWD}" ] ; then + local used=$(command df -P "$PWD" | + awk 'END {print $5} {sub(/%/,"")}') + if [ ${used} -gt 95 ]; then + echo -en ${ALERT} # Disk almost full (>95%). + elif [ ${used} -gt 90 ]; then + echo -en ${BRed} # Free disk space almost gone. + else + echo -en ${Green} # Free disk space is ok. + fi + else + echo -en ${Cyan} + # Current directory is size '0' (like /proc, /sys etc). + fi +} + +# Returns a color according to running/suspended jobs. +function job_color() +{ + if [ $(jobs -s | wc -l) -gt "0" ]; then + echo -en ${BRed} + elif [ $(jobs -r | wc -l) -gt "0" ] ; then + echo -en ${BCyan} + fi +} + +# Adds some text in the terminal frame (if applicable). + + +# Now we construct the prompt. +PROMPT_COMMAND="history -a" +case ${TERM} in + *term | rxvt | linux) + PS1="\[\$(load_color)\][\A\[${NC}\] " + # Time of day (with load info): + PS1="\[\$(load_color)\][\A\[${NC}\] " + # User@Host (with connection type info): + PS1=${PS1}"\[${SU}\]\u\[${NC}\]@\[${CNX}\]\h\[${NC}\] " + # PWD (with 'disk space' info): + PS1=${PS1}"\[\$(disk_color)\]\W]\[${NC}\] " + # Prompt (with 'job' info): + PS1=${PS1}"\[\$(job_color)\]>\[${NC}\] " + # Set title of current xterm: + PS1=${PS1}"\[\e]0;[\u@\h] \w\a\]" + ;; + *) + PS1="(\A \u@\h \W) > " # --> PS1="(\A \u@\h \w) > " + # --> Shows full pathname of current dir. + ;; +esac + + + +export TIMEFORMAT=$'\nreal %3R\tuser %3U\tsys %3S\tpcpu %P\n' +export HISTIGNORE="&:bg:fg:ll:h" +export HISTTIMEFORMAT="$(echo -e ${BCyan})[%d/%m %H:%M:%S]$(echo -e ${NC}) " +export HISTCONTROL=ignoredups +export HOSTFILE=$HOME/.hosts # Put a list of remote hosts in ~/.hosts + + +#============================================================ +# +# ALIASES AND FUNCTIONS +# +# Arguably, some functions defined here are quite big. +# If you want to make this file smaller, these functions can +#+ be converted into scripts and removed from here. +# +#============================================================ + +#------------------- +# Personnal Aliases +#------------------- + +alias rm='rm -i' +alias cp='cp -i' +alias mv='mv -i' +# -> Prevents accidentally clobbering files. +alias mkdir='mkdir -p' + +alias h='history' +alias j='jobs -l' +alias which='type -a' +alias ..='cd ..' +alias ...='cd ../..' + +# Pretty-print of some PATH variables: +alias path='echo -e ${PATH//:/\\n}' +alias libpath='echo -e ${LD_LIBRARY_PATH//:/\\n}' + + +alias du='du -kh' # Makes a more readable output. +alias df='df -kTh' + +#------------------------------------------------------------- +# The 'ls' family (this assumes you use a recent GNU ls). +#------------------------------------------------------------- +# Add colors for filetype and human-readable sizes by default on 'ls': +alias ls='ls -h --color' +alias lx='ls -lXB' # Sort by extension. +alias lk='ls -lSr' # Sort by size, biggest last. +alias lt='ls -ltr' # Sort by date, most recent last. +alias lc='ls -ltcr' # Sort by/show change time,most recent last. +alias lu='ls -ltur' # Sort by/show access time,most recent last. + +# The ubiquitous 'll': directories first, with alphanumeric sorting: +alias ll="ls -lv --group-directories-first" +alias lm='ll |more' # Pipe through 'more' +alias lr='ll -R' # Recursive ls. +alias la='ll -A' # Show hidden files. +alias l='la' +alias tree='tree -Csuh' # Nice alternative to 'recursive ls' ... + + +#------------------------------------------------------------- +# Tailoring 'less' +#------------------------------------------------------------- + +alias more='less' +export PAGER=less +export LESSCHARSET='latin1' +export LESSOPEN='|/usr/bin/lesspipe.sh %s 2>&-' + # Use this if lesspipe.sh exists. +export LESS='-i -N -w -z-4 -g -e -M -X -F -R -P%t?f%f \ +:stdin .?pb%pb\%:?lbLine %lb:?bbByte %bb:-...' + +# LESS man page colors (makes Man pages more readable). +export LESS_TERMCAP_mb=$'\E[01;31m' +export LESS_TERMCAP_md=$'\E[01;31m' +export LESS_TERMCAP_me=$'\E[0m' +export LESS_TERMCAP_se=$'\E[0m' +export LESS_TERMCAP_so=$'\E[01;44;33m' +export LESS_TERMCAP_ue=$'\E[0m' +export LESS_TERMCAP_us=$'\E[01;32m' + + +#------------------------------------------------------------- +# Spelling typos - highly personnal and keyboard-dependent :-) +#------------------------------------------------------------- + +alias xs='cd' +alias vf='cd' +alias moer='more' +alias moew='more' +alias kk='ll' + + +#------------------------------------------------------------- +# A few fun ones +#------------------------------------------------------------- + +# Adds some text in the terminal frame (if applicable). + +function xtitle() +{ + case "$TERM" in + *term* | rxvt) + echo -en "\e]0;$*\a" ;; + *) ;; + esac +} + + +# Aliases that use xtitle +alias top='xtitle Processes on $HOST && top' +alias make='xtitle Making $(basename $PWD) ; make' + +# .. and functions +function man() +{ + for i ; do + xtitle The $(basename $1|tr -d .[:digit:]) manual + command man -a "$i" + done +} + + +#------------------------------------------------------------- +# Make the following commands run in background automatically: +#------------------------------------------------------------- + +function te() # wrapper around xemacs/gnuserv +{ + if [ "$(gnuclient -batch -eval t 2>&-)" == "t" ]; then + gnuclient -q "$@"; + else + ( xemacs "$@" &); + fi +} + +function soffice() { command soffice "$@" & } +function firefox() { command firefox "$@" & } +function xpdf() { command xpdf "$@" & } + + +#------------------------------------------------------------- +# File & strings related functions: +#------------------------------------------------------------- + + +# Find a file with a pattern in name: +function ff() { find . -type f -iname '*'"$*"'*' -ls ; } + +# Find a file with pattern $1 in name and Execute $2 on it: +function fe() { find . -type f -iname '*'"${1:-}"'*' \ +-exec ${2:-file} {} \; ; } + +# Find a pattern in a set of files and highlight them: +#+ (needs a recent version of egrep). +function fstr() +{ + OPTIND=1 + local mycase="" + local usage="fstr: find string in files. +Usage: fstr [-i] \"pattern\" [\"filename pattern\"] " + while getopts :it opt + do + case "$opt" in + i) mycase="-i " ;; + *) echo "$usage"; return ;; + esac + done + shift $(( $OPTIND - 1 )) + if [ "$#" -lt 1 ]; then + echo "$usage" + return; + fi + find . -type f -name "${2:-*}" -print0 | \ +xargs -0 egrep --color=always -sn ${case} "$1" 2>&- | more + +} + + +function swap() +{ # Swap 2 filenames around, if they exist (from Uzi's bashrc). + local TMPFILE=tmp.$$ + + [ $# -ne 2 ] && echo "swap: 2 arguments needed" && return 1 + [ ! -e $1 ] && echo "swap: $1 does not exist" && return 1 + [ ! -e $2 ] && echo "swap: $2 does not exist" && return 1 + + mv "$1" $TMPFILE + mv "$2" "$1" + mv $TMPFILE "$2" +} + +function extract() # Handy Extract Program +{ + if [ -f $1 ] ; then + case $1 in + *.tar.bz2) tar xvjf $1 ;; + *.tar.gz) tar xvzf $1 ;; + *.bz2) bunzip2 $1 ;; + *.rar) unrar x $1 ;; + *.gz) gunzip $1 ;; + *.tar) tar xvf $1 ;; + *.tbz2) tar xvjf $1 ;; + *.tgz) tar xvzf $1 ;; + *.zip) unzip $1 ;; + *.Z) uncompress $1 ;; + *.7z) 7z x $1 ;; + *) echo "'$1' cannot be extracted via >extract<" ;; + esac + else + echo "'$1' is not a valid file!" + fi +} + + +# Creates an archive (*.tar.gz) from given directory. +function maketar() { tar cvzf "${1%%/}.tar.gz" "${1%%/}/"; } + +# Create a ZIP archive of a file or folder. +function makezip() { zip -r "${1%%/}.zip" "$1" ; } + +# Make your directories and files access rights sane. +function sanitize() { chmod -R u=rwX,g=rX,o= "$@" ;} + +#------------------------------------------------------------- +# Process/system related functions: +#------------------------------------------------------------- + + +function my_ps() { ps $@ -u $USER -o pid,%cpu,%mem,bsdtime,command ; } +function pp() { my_ps f | awk '!/awk/ && $0~var' var=${1:-".*"} ; } + + +function killps() # kill by process name +{ + local pid pname sig="-TERM" # default signal + if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: killps [-SIGNAL] pattern" + return; + fi + if [ $# = 2 ]; then sig=$1 ; fi + for pid in $(my_ps| awk '!/awk/ && $0~pat { print $1 }' pat=${!#} ) + do + pname=$(my_ps | awk '$1~var { print $5 }' var=$pid ) + if ask "Kill process $pid <$pname> with signal $sig?" + then kill $sig $pid + fi + done +} + +function mydf() # Pretty-print of 'df' output. +{ # Inspired by 'dfc' utility. + for fs ; do + + if [ ! -d $fs ] + then + echo -e $fs" :No such file or directory" ; continue + fi + + local info=( $(command df -P $fs | awk 'END{ print $2,$3,$5 }') ) + local free=( $(command df -Pkh $fs | awk 'END{ print $4 }') ) + local nbstars=$(( 20 * ${info[1]} / ${info[0]} )) + local out="[" + for ((j=0;j<20;j++)); do + if [ ${j} -lt ${nbstars} ]; then + out=$out"*" + else + out=$out"-" + fi + done + out=${info[2]}" "$out"] ("$free" free on "$fs")" + echo -e $out + done +} + + +function my_ip() # Get IP adress on ethernet. +{ + MY_IP=$(/sbin/ifconfig eth0 | awk '/inet/ { print $2 } ' | + sed -e s/addr://) + echo ${MY_IP:-"Not connected"} +} + +function ii() # Get current host related info. +{ + echo -e "\nYou are logged on ${BRed}$HOST" + echo -e "\n${BRed}Additionnal information:$NC " ; uname -a + echo -e "\n${BRed}Users logged on:$NC " ; w -hs | + cut -d " " -f1 | sort | uniq + echo -e "\n${BRed}Current date :$NC " ; date + echo -e "\n${BRed}Machine stats :$NC " ; uptime + echo -e "\n${BRed}Memory stats :$NC " ; free + echo -e "\n${BRed}Diskspace :$NC " ; mydf / $HOME + echo -e "\n${BRed}Local IP Address :$NC" ; my_ip + echo -e "\n${BRed}Open connections :$NC "; netstat -pan --inet; + echo +} + +#------------------------------------------------------------- +# Misc utilities: +#------------------------------------------------------------- + +function repeat() # Repeat n times command. +{ + local i max + max=$1; shift; + for ((i=1; i <= max ; i++)); do # --> C-like syntax + eval "$@"; + done +} + + +function ask() # See 'killps' for example of use. +{ + echo -n "$@" '[y/n] ' ; read ans + case "$ans" in + y*|Y*) return 0 ;; + *) return 1 ;; + esac +} + +function corename() # Get name of app that created a corefile. +{ + for file ; do + echo -n $file : ; gdb --core=$file --batch | head -1 + done +} + + + +#========================================================================= +# +# PROGRAMMABLE COMPLETION SECTION +# Most are taken from the bash 2.05 documentation and from Ian McDonald's +# 'Bash completion' package (http://www.caliban.org/bash/#completion) +# You will in fact need bash more recent then 3.0 for some features. +# +# Note that most linux distributions now provide many completions +# 'out of the box' - however, you might need to make your own one day, +# so I kept those here as examples. +#========================================================================= + +if [ "${BASH_VERSION%.*}" \< "3.0" ]; then + echo "You will need to upgrade to version 3.0 for full \ + programmable completion features" + return +fi + +shopt -s extglob # Necessary. + +complete -A hostname rsh rcp telnet rlogin ftp ping disk +complete -A export printenv +complete -A variable export local readonly unset +complete -A enabled builtin +complete -A alias alias unalias +complete -A function function +complete -A user su mail finger + +complete -A helptopic help # Currently same as builtins. +complete -A shopt shopt +complete -A stopped -P '%' bg +complete -A job -P '%' fg jobs disown + +complete -A directory mkdir rmdir +complete -A directory -o default cd + +# Compression +complete -f -o default -X '*.+(zip|ZIP)' zip +complete -f -o default -X '!*.+(zip|ZIP)' unzip +complete -f -o default -X '*.+(z|Z)' compress +complete -f -o default -X '!*.+(z|Z)' uncompress +complete -f -o default -X '*.+(gz|GZ)' gzip +complete -f -o default -X '!*.+(gz|GZ)' gunzip +complete -f -o default -X '*.+(bz2|BZ2)' bzip2 +complete -f -o default -X '!*.+(bz2|BZ2)' bunzip2 +complete -f -o default -X '!*.+(zip|ZIP|z|Z|gz|GZ|bz2|BZ2)' extract + + +# Documents - Postscript,pdf,dvi..... +complete -f -o default -X '!*.+(ps|PS)' gs ghostview ps2pdf ps2ascii +complete -f -o default -X \ +'!*.+(dvi|DVI)' dvips dvipdf xdvi dviselect dvitype +complete -f -o default -X '!*.+(pdf|PDF)' acroread pdf2ps +complete -f -o default -X '!*.@(@(?(e)ps|?(E)PS|pdf|PDF)?\ +(.gz|.GZ|.bz2|.BZ2|.Z))' gv ggv +complete -f -o default -X '!*.texi*' makeinfo texi2dvi texi2html texi2pdf +complete -f -o default -X '!*.tex' tex latex slitex +complete -f -o default -X '!*.lyx' lyx +complete -f -o default -X '!*.+(htm*|HTM*)' lynx html2ps +complete -f -o default -X \ +'!*.+(doc|DOC|xls|XLS|ppt|PPT|sx?|SX?|csv|CSV|od?|OD?|ott|OTT)' soffice + +# Multimedia +complete -f -o default -X \ +'!*.+(gif|GIF|jp*g|JP*G|bmp|BMP|xpm|XPM|png|PNG)' xv gimp ee gqview +complete -f -o default -X '!*.+(mp3|MP3)' mpg123 mpg321 +complete -f -o default -X '!*.+(ogg|OGG)' ogg123 +complete -f -o default -X \ +'!*.@(mp[23]|MP[23]|ogg|OGG|wav|WAV|pls|\ +m3u|xm|mod|s[3t]m|it|mtm|ult|flac)' xmms +complete -f -o default -X '!*.@(mp?(e)g|MP?(E)G|wma|avi|AVI|\ +asf|vob|VOB|bin|dat|vcd|ps|pes|fli|viv|rm|ram|yuv|mov|MOV|qt|\ +QT|wmv|mp3|MP3|ogg|OGG|ogm|OGM|mp4|MP4|wav|WAV|asx|ASX)' xine + + + +complete -f -o default -X '!*.pl' perl perl5 + + +# This is a 'universal' completion function - it works when commands have +#+ a so-called 'long options' mode , ie: 'ls --all' instead of 'ls -a' +# Needs the '-o' option of grep +#+ (try the commented-out version if not available). + +# First, remove '=' from completion word separators +#+ (this will allow completions like 'ls --color=auto' to work correctly). + +COMP_WORDBREAKS=${COMP_WORDBREAKS/=/} + + +_get_longopts() +{ + #$1 --help | sed -e '/--/!d' -e 's/.*--\([^[:space:].,]*\).*/--\1/'| \ + #grep ^"$2" |sort -u ; + $1 --help | grep -o -e "--[^[:space:].,]*" | grep -e "$2" |sort -u +} + +_longopts() +{ + local cur + cur=${COMP_WORDS[COMP_CWORD]} + + case "${cur:-*}" in + -*) ;; + *) return ;; + esac + + case "$1" in + \~*) eval cmd="$1" ;; + *) cmd="$1" ;; + esac + COMPREPLY=( $(_get_longopts ${1} ${cur} ) ) +} +complete -o default -F _longopts configure bash +complete -o default -F _longopts wget id info a2ps ls recode + +_tar() +{ + local cur ext regex tar untar + + COMPREPLY=() + cur=${COMP_WORDS[COMP_CWORD]} + + # If we want an option, return the possible long options. + case "$cur" in + -*) COMPREPLY=( $(_get_longopts $1 $cur ) ); return 0;; + esac + + if [ $COMP_CWORD -eq 1 ]; then + COMPREPLY=( $( compgen -W 'c t x u r d A' -- $cur ) ) + return 0 + fi + + case "${COMP_WORDS[1]}" in + ?(-)c*f) + COMPREPLY=( $( compgen -f $cur ) ) + return 0 + ;; + +([^Izjy])f) + ext='tar' + regex=$ext + ;; + *z*f) + ext='tar.gz' + regex='t\(ar\.\)\(gz\|Z\)' + ;; + *[Ijy]*f) + ext='t?(ar.)bz?(2)' + regex='t\(ar\.\)bz2\?' + ;; + *) + COMPREPLY=( $( compgen -f $cur ) ) + return 0 + ;; + + esac + + if [[ "$COMP_LINE" == tar*.$ext' '* ]]; then + # Complete on files in tar file. + # + # Get name of tar file from command line. + tar=$( echo "$COMP_LINE" | \ + sed -e 's|^.* \([^ ]*'$regex'\) .*$|\1|' ) + # Devise how to untar and list it. + untar=t${COMP_WORDS[1]//[^Izjyf]/} + + COMPREPLY=( $( compgen -W "$( echo $( tar $untar $tar \ + 2>/dev/null ) )" -- "$cur" ) ) + return 0 + + else + # File completion on relevant files. + COMPREPLY=( $( compgen -G $cur\*.$ext ) ) + + fi + + return 0 + +} + +complete -F _tar -o default tar + +_make() +{ + local mdef makef makef_dir="." makef_inc gcmd cur prev i; + COMPREPLY=(); + cur=${COMP_WORDS[COMP_CWORD]}; + prev=${COMP_WORDS[COMP_CWORD-1]}; + case "$prev" in + -*f) + COMPREPLY=($(compgen -f $cur )); + return 0 + ;; + esac; + case "$cur" in + -*) + COMPREPLY=($(_get_longopts $1 $cur )); + return 0 + ;; + esac; + + # ... make reads + # GNUmakefile, + # then makefile + # then Makefile ... + if [ -f ${makef_dir}/GNUmakefile ]; then + makef=${makef_dir}/GNUmakefile + elif [ -f ${makef_dir}/makefile ]; then + makef=${makef_dir}/makefile + elif [ -f ${makef_dir}/Makefile ]; then + makef=${makef_dir}/Makefile + else + makef=${makef_dir}/*.mk # Local convention. + fi + + + # Before we scan for targets, see if a Makefile name was + #+ specified with -f. + for (( i=0; i < ${#COMP_WORDS[@]}; i++ )); do + if [[ ${COMP_WORDS[i]} == -f ]]; then + # eval for tilde expansion + eval makef=${COMP_WORDS[i+1]} + break + fi + done + [ ! -f $makef ] && return 0 + + # Deal with included Makefiles. + makef_inc=$( grep -E '^-?include' $makef | + sed -e "s,^.* ,"$makef_dir"/," ) + for file in $makef_inc; do + [ -f $file ] && makef="$makef $file" + done + + + # If we have a partial word to complete, restrict completions + #+ to matches of that word. + if [ -n "$cur" ]; then gcmd='grep "^$cur"' ; else gcmd=cat ; fi + + COMPREPLY=( $( awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ \ + {split($1,A,/ /);for(i in A)print A[i]}' \ + $makef 2>/dev/null | eval $gcmd )) + +} + +complete -F _make -X '+($*|*.[cho])' make gmake pmake + + + + +_killall() +{ + local cur prev + COMPREPLY=() + cur=${COMP_WORDS[COMP_CWORD]} + + # Get a list of processes + #+ (the first sed evaluation + #+ takes care of swapped out processes, the second + #+ takes care of getting the basename of the process). + COMPREPLY=( $( ps -u $USER -o comm | \ + sed -e '1,1d' -e 's#[]\[]##g' -e 's#^.*/##'| \ + awk '{if ($0 ~ /^'$cur'/) print $0}' )) + + return 0 +} + +complete -F _killall killall killps + + + +# Local Variables: +# mode:shell-script +# sh-shell:bash +# End: \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..12143a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +__pycache__ +media +import_olddb +db.sqlite3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0a2fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Server config files +nginx_note.conf + +# Byte-compiled / optimized / DLL files +dist +build +__pycache__ +*.py[cod] +*$py.class +*.swp +*.egg-info +_build +.tox +.coverage +coverage + +# Translations +*.mo +*.pot + +# Jupyter Notebook +.ipynb_checkpoints + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# PyCharm project settings +.idea + +# VSCode project settings +.vscode + +# Local data +secrets.py +*.log +media/ +# Virtualenv +env/ +venv/ +db.sqlite3 + +# Ignore migrations during first phase dev +migrations/ + +# Don't git personal data +import_olddb/ diff --git a/Dockerfile b/Dockerfile index 7bfdd64..71017de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,29 @@ -FROM php:7.3-apache as plateforme-builder +FROM python:3-alpine -# Enabling apache rewrite mod -RUN a2enmod rewrite +ENV PYTHONUNBUFFERED 1 -RUN apt clean && apt update && apt upgrade -y +# Install LaTeX requirements +RUN apk add --no-cache gettext texlive nginx gcc libc-dev libffi-dev postgresql-dev mariadb-connector-c-dev -# Install MySQL drivers -RUN docker-php-ext-install pdo_mysql \ - && docker-php-ext-enable pdo_mysql +RUN apk add --no-cache bash -# Install zip utilities -RUN apt install -y libzip-dev zip \ - && docker-php-ext-configure zip --with-libzip \ - && docker-php-ext-install zip \ - && docker-php-ext-enable zip +RUN mkdir /code +WORKDIR /code +COPY requirements.txt /code/requirements.txt +RUN pip install -r requirements.txt --no-cache-dir -# Install LaTeX utilities -RUN apt update && apt upgrade -y && apt install -yq texlive texlive-base texlive-binaries texlive-lang-french +COPY . /code/ -# Setup locales -RUN apt install locales locales-all -y && locale-gen fr_FR.UTF-8 -ENV LANG fr_FR.UTF-8 -ENV LANGUAGE fr_FR:fr -ENV LC_ALL fr_FR.UTF-8 +# Configure nginx +RUN mkdir /run/nginx +RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log +RUN ln -sf /code/nginx_tfjm.conf /etc/nginx/conf.d/tfjm.conf +RUN rm /etc/nginx/conf.d/default.conf -# Setup timezone -RUN echo Europe/Paris > /etc/timezone \ - && ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime \ - && dpkg-reconfigure -f noninteractive tzdata +# With a bashrc, the shell is better +RUN ln -s /code/.bashrc /root/.bashrc -# Setup mailing -RUN apt install -yq msmtp ca-certificates -COPY setup/msmtprc /etc/msmtprc -RUN echo "sendmail_path=msmtp -t" >> /usr/local/etc/php/conf.d/php-sendmail.ini +ENTRYPOINT ["/code/entrypoint.sh"] +EXPOSE 80 -# Setting environment -ENV TFJM_LOCAL_PATH /var/www/html -ENV TFJM_MAIL_DOMAIN tfjm.org -ENV TFJM_URL_BASE https://inscription.tfjm.org +CMD ["./manage.py", "shell_plus", "--ptpython"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9459c3 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Plateforme d'inscription du TFJM² + +La plateforme du TFJM² est née pour l'édition 2020 du tournoi. D'abord codée en PHP, elle a subi une refonte totale en +Python, à l'aide du framework Web [Django](https://www.djangoproject.com/). + +Cette plateforme permet aux participants et encadrants de s'inscrire et de déposer leurs autorisations nécessaires. +Ils pourront ensuite déposer leurs solutions et notes de synthèse pour le premier tour en temps voulu. La plateforme +offre également un accès pour les organisateurs et les jurys leur permettant de communiquer avec les équipes et de +récupérer les documents nécessaires. + +Un wiki plus détaillé arrivera ultérieurement. L'interface organisateur et jury est vouée à être plus poussée. + +L'instance de production est disponible à l'adresse [inscription.tfjm.org](https://inscription.tfjm.org). + +## Installation + +Le plus simple pour installer la plateforme est d'utiliser l'image Docker incluse, qui fait tourner un serveur Nginx +exposé sur le port 80 avec le serveur Django. Ci-dessous une configuration Docker-Compose, à adapter selon vos besoins : + +```yaml + inscription-tfjm: + build: ./inscription-tfjm + links: + - postgres + ports: + - "80:80" + env_file: + - ./inscription-tfjm.env + volumes: + # - ./inscription-tfjm:/code + - ./inscription-tfjm/media:/code/media +``` + +Le volume `/code` n'est à ajouter uniquement en développement, et jamais en production. + +Il faut remplir les variables d'environnement suivantes : + +```env +TFJM_STAGE= # dev ou prod +TFJM_YEAR=2021 # Année de la session du TFJM² +DJANGO_DB_TYPE= # MySQL, PostgreSQL ou SQLite (par défaut) +DJANGO_DB_HOST= # Hôte de la base de données +DJANGO_DB_NAME= # Nom de la base de données +DJANGO_DB_USER= # Utilisateur de la base de données +DJANGO_DB_PASSWORD= # Mot de passe pour accéder à la base de données +SMTP_HOST= # Hôte SMTP pour l'envoi de mails +SMTP_PORT=465 # Port du serveur SMTP +SMTP_HOST_USER= # Utilisateur du compte SMTP +SMTP_HOST_PASSWORD= # Mot de passe du compte SMTP +FROM_EMAIL=contact@tfjm.org # Nom de l'expéditeur des mails +SERVER_EMAIL=contact@tfjm.org # Adresse e-mail expéditrice +``` + +Si le type de base de données sélectionné est SQLite, la variable `DJANGO_DB_HOST` sera utilisée en guise de chemin vers +le fichier de base de données (par défaut, `db.sqlite3`). + +En développement, il est recommandé d'utiliser SQLite pour des raisons de simplicité. Les paramètres de mail ne seront +pas utilisés, et les mails qui doivent être envoyés seront envoyés dans la console. + +En production, il est recommandé de ne pas utiliser SQLite pour des raisons de performances. + +La dernière différence entre le développment et la production est qu'en développement, chaque modification d'un fichier +est détectée et le serveur se relance automatiquement dès lors. + +Une fois le site lancé, le premier compte créé sera un compte administrateur. \ No newline at end of file diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..08884cb --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1 @@ +default_app_config = 'api.apps.APIConfig' diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..6e03468 --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class APIConfig(AppConfig): + """ + Manage the inscription through a JSON API. + """ + name = 'api' + verbose_name = _('API') diff --git a/apps/api/serializers.py b/apps/api/serializers.py new file mode 100644 index 0000000..1685020 --- /dev/null +++ b/apps/api/serializers.py @@ -0,0 +1,80 @@ +from rest_framework import serializers +from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis +from tournament.models import Team, Tournament, Pool + + +class UserSerializer(serializers.ModelSerializer): + """ + Serialize a User object into JSON. + """ + class Meta: + model = TFJMUser + exclude = ( + 'username', + 'password', + 'groups', + 'user_permissions', + ) + + +class TeamSerializer(serializers.ModelSerializer): + """ + Serialize a Team object into JSON. + """ + class Meta: + model = Team + fields = "__all__" + + +class TournamentSerializer(serializers.ModelSerializer): + """ + Serialize a Tournament object into JSON. + """ + class Meta: + model = Tournament + fields = "__all__" + + +class AuthorizationSerializer(serializers.ModelSerializer): + """ + Serialize an Authorization object into JSON. + """ + class Meta: + model = Authorization + fields = "__all__" + + +class MotivationLetterSerializer(serializers.ModelSerializer): + """ + Serialize a MotivationLetter object into JSON. + """ + class Meta: + model = MotivationLetter + fields = "__all__" + + +class SolutionSerializer(serializers.ModelSerializer): + """ + Serialize a Solution object into JSON. + """ + class Meta: + model = Solution + fields = "__all__" + + +class SynthesisSerializer(serializers.ModelSerializer): + """ + Serialize a Synthesis object into JSON. + """ + class Meta: + model = Synthesis + fields = "__all__" + + +class PoolSerializer(serializers.ModelSerializer): + """ + Serialize a Pool object into JSON. + """ + class Meta: + model = Pool + fields = "__all__" diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..b2e617f --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,26 @@ +from django.conf.urls import url, include +from rest_framework import routers + +from .viewsets import UserViewSet, TeamViewSet, TournamentViewSet, AuthorizationViewSet, MotivationLetterViewSet, \ + SolutionViewSet, SynthesisViewSet, PoolViewSet + +# Routers provide an easy way of automatically determining the URL conf. +# Register each app API router and user viewset +router = routers.DefaultRouter() +router.register('user', UserViewSet) +router.register('team', TeamViewSet) +router.register('tournament', TournamentViewSet) +router.register('authorization', AuthorizationViewSet) +router.register('motivation_letter', MotivationLetterViewSet) +router.register('solution', SolutionViewSet) +router.register('synthesis', SynthesisViewSet) +router.register('pool', PoolViewSet) + +app_name = 'api' + +# Wire up our API using automatic URL routing. +# Additionally, we include login URLs for the browsable API. +urlpatterns = [ + url('^', include(router.urls)), + url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')), +] diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py new file mode 100644 index 0000000..785e446 --- /dev/null +++ b/apps/api/viewsets.py @@ -0,0 +1,124 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status +from rest_framework.filters import SearchFilter +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis +from tournament.models import Team, Tournament, Pool + +from .serializers import UserSerializer, TeamSerializer, TournamentSerializer, AuthorizationSerializer, \ + MotivationLetterSerializer, SolutionSerializer, SynthesisSerializer, PoolSerializer + + +class UserViewSet(ModelViewSet): + """ + Display list of users. + """ + queryset = TFJMUser.objects.all() + serializer_class = UserSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['id', 'first_name', 'last_name', 'email', 'gender', 'student_class', 'role', 'year', 'team', + 'team__trigram', 'is_superuser', 'is_staff', 'is_active', ] + search_fields = ['$first_name', '$last_name', ] + + +class TeamViewSet(ModelViewSet): + """ + Display list of teams. + """ + queryset = Team.objects.all() + serializer_class = TeamSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'trigram', 'validation_status', 'selected_for_final', 'access_code', 'tournament', + 'year', ] + search_fields = ['$name', 'trigram', ] + + +class TournamentViewSet(ModelViewSet): + """ + Display list of tournaments. + """ + queryset = Tournament.objects.all() + serializer_class = TournamentSerializer + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['name', 'size', 'price', 'date_start', 'date_end', 'final', 'organizers', 'year', ] + search_fields = ['$name', ] + + +class AuthorizationViewSet(ModelViewSet): + """ + Display list of authorizations. + """ + queryset = Authorization.objects.all() + serializer_class = AuthorizationSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['user', 'type', ] + + +class MotivationLetterViewSet(ModelViewSet): + """ + Display list of motivation letters. + """ + queryset = MotivationLetter.objects.all() + serializer_class = MotivationLetterSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', ] + + +class SolutionViewSet(ModelViewSet): + """ + Display list of solutions. + """ + queryset = Solution.objects.all() + serializer_class = SolutionSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', 'problem', ] + + +class SynthesisViewSet(ModelViewSet): + """ + Display list of syntheses. + """ + queryset = Synthesis.objects.all() + serializer_class = SynthesisSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['team', 'team__trigram', 'source', 'round', ] + + +class PoolViewSet(ModelViewSet): + """ + Display list of pools. + If the request is a POST request and the format is "A;X;x;Y;y;Z;z;..." where A = 1 or 1 = 2, + X, Y, Z, ... are team trigrams, x, y, z, ... are numbers of problems, then this is interpreted as a + creation a pool for the round A with the solutions of problems x, y, z, ... of the teams X, Y, Z, ... respectively. + """ + queryset = Pool.objects.all() + serializer_class = PoolSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['teams', 'teams__trigram', 'round', ] + + def create(self, request, *args, **kwargs): + data = request.data + try: + spl = data.split(";") + if len(spl) >= 7: + round = int(spl[0]) + teams = [] + solutions = [] + for i in range((len(spl) - 1) // 2): + trigram = spl[1 + 2 * i] + pb = int(spl[2 + 2 * i]) + team = Team.objects.get(trigram=trigram) + solution = Solution.objects.get(team=team, problem=pb, final=team.selected_for_final) + teams.append(team) + solutions.append(solution) + pool = Pool.objects.create(round=round) + pool.teams.set(teams) + pool.solutions.set(solutions) + pool.save() + serializer = PoolSerializer(pool) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + except BaseException: # JSON data + pass + return super().create(request, *args, **kwargs) \ No newline at end of file diff --git a/apps/member/__init__.py b/apps/member/__init__.py new file mode 100644 index 0000000..6bb559b --- /dev/null +++ b/apps/member/__init__.py @@ -0,0 +1 @@ +default_app_config = 'member.apps.MemberConfig' diff --git a/apps/member/admin.py b/apps/member/admin.py new file mode 100644 index 0000000..a41bb92 --- /dev/null +++ b/apps/member/admin.py @@ -0,0 +1,56 @@ +from django.contrib.auth.admin import admin +from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin +from member.models import TFJMUser, Document, Solution, Synthesis, MotivationLetter, Authorization, Config + + +@admin.register(TFJMUser) +class TFJMUserAdmin(admin.ModelAdmin): + """ + Django admin page for users. + """ + list_display = ('email', 'first_name', 'last_name', 'role', ) + search_fields = ('last_name', 'first_name',) + + +@admin.register(Document) +class DocumentAdmin(PolymorphicParentModelAdmin): + """ + Django admin page for any documents. + """ + child_models = (Authorization, MotivationLetter, Solution, Synthesis,) + polymorphic_list = True + + +@admin.register(Authorization) +class AuthorizationAdmin(PolymorphicChildModelAdmin): + """ + Django admin page for Authorization. + """ + + +@admin.register(MotivationLetter) +class MotivationLetterAdmin(PolymorphicChildModelAdmin): + """ + Django admin page for Motivation letters. + """ + + +@admin.register(Solution) +class SolutionAdmin(PolymorphicChildModelAdmin): + """ + Django admin page for solutions. + """ + + +@admin.register(Synthesis) +class SynthesisAdmin(PolymorphicChildModelAdmin): + """ + Django admin page for syntheses. + """ + + +@admin.register(Config) +class ConfigAdmin(admin.ModelAdmin): + """ + Django admin page for configurations. + """ diff --git a/apps/member/apps.py b/apps/member/apps.py new file mode 100644 index 0000000..61c9ae8 --- /dev/null +++ b/apps/member/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class MemberConfig(AppConfig): + """ + The member app handles the information that concern a user, its documents, ... + """ + name = 'member' + verbose_name = _('member') diff --git a/apps/member/forms.py b/apps/member/forms.py new file mode 100644 index 0000000..083b7b4 --- /dev/null +++ b/apps/member/forms.py @@ -0,0 +1,73 @@ +from django.contrib.auth.forms import UserCreationForm +from django import forms +from django.utils.translation import gettext_lazy as _ + +from .models import TFJMUser + + +class SignUpForm(UserCreationForm): + """ + Coaches and participants register on the website through this form. + TODO: Check if this form works, render it better + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["first_name"].required = True + self.fields["last_name"].required = True + self.fields["role"].choices = [ + ('', _("Choose a role...")), + ('3participant', _("Participant")), + ('2coach', _("Coach")), + ] + + class Meta: + model = TFJMUser + fields = ( + 'role', + 'email', + 'first_name', + 'last_name', + 'birth_date', + 'gender', + 'address', + 'postal_code', + 'city', + 'country', + 'phone_number', + 'school', + 'student_class', + 'responsible_name', + 'responsible_phone', + 'responsible_email', + 'description', + ) + + +class TFJMUserForm(forms.ModelForm): + """ + Form to update our own information when we are participant. + """ + class Meta: + model = TFJMUser + fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code', + 'city', 'country', 'school', 'student_class', 'responsible_name', 'responsible_phone', + 'responsible_email',) + + +class CoachUserForm(forms.ModelForm): + """ + Form to update our own information when we are coach. + """ + class Meta: + model = TFJMUser + fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code', + 'city', 'country', 'description',) + + +class AdminUserForm(forms.ModelForm): + """ + Form to update our own information when we are organizer or admin. + """ + class Meta: + model = TFJMUser + fields = ('last_name', 'first_name', 'email', 'phone_number', 'description',) diff --git a/apps/member/management/__init__.py b/apps/member/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/member/management/commands/__init__.py b/apps/member/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/member/management/commands/create_su.py b/apps/member/management/commands/create_su.py new file mode 100644 index 0000000..93ec091 --- /dev/null +++ b/apps/member/management/commands/create_su.py @@ -0,0 +1,32 @@ +import os +from datetime import date +from getpass import getpass +from django.core.management import BaseCommand +from member.models import TFJMUser + + +class Command(BaseCommand): + def handle(self, *args, **options): + """ + Little script that generate a superuser. + """ + email = input("Email: ") + password = "1" + confirm_password = "2" + while password != confirm_password: + password = getpass("Password: ") + confirm_password = getpass("Confirm password: ") + if password != confirm_password: + self.stderr.write(self.style.ERROR("Passwords don't match.")) + + user = TFJMUser.objects.create( + email=email, + password="", + role="admin", + year=os.getenv("TFJM_YEAR", date.today().year), + is_active=True, + is_staff=True, + is_superuser=True, + ) + user.set_password(password) + user.save() diff --git a/apps/member/management/commands/extract_solutions.py b/apps/member/management/commands/extract_solutions.py new file mode 100644 index 0000000..7b59ad1 --- /dev/null +++ b/apps/member/management/commands/extract_solutions.py @@ -0,0 +1,75 @@ +import os +from urllib.request import urlretrieve +from shutil import copyfile + +from django.core.management import BaseCommand +from django.utils import translation +from member.models import Solution +from tournament.models import Tournament + + +class Command(BaseCommand): + PROBLEMS = [ + 'Création de puzzles', + 'Départ en vacances', + 'Un festin stratégique', + 'Sauver les meubles', + 'Prêt à décoller !', + 'Ils nous espionnent !', + 'De joyeux bûcherons', + 'Robots auto-réplicateurs', + ] + + def add_arguments(self, parser): + parser.add_argument('dir', + type=str, + default='.', + help="Directory where solutions should be saved.") + parser.add_argument('--language', '-l', + type=str, + choices=['en', 'fr'], + default='fr', + help="Language of the title of the files.") + + def handle(self, *args, **options): + """ + Copy solutions elsewhere. + """ + d = options['dir'] + teams_dir = d + '/Par équipe' + os.makedirs(teams_dir, exist_ok=True) + + translation.activate(options['language']) + + copied = 0 + + for tournament in Tournament.objects.all(): + os.mkdir(teams_dir + '/' + tournament.name) + for team in tournament.teams.filter(validation_status='2valid'): + os.mkdir(teams_dir + '/' + tournament.name + '/' + str(team)) + for sol in tournament.solutions: + if not os.path.isfile('media/' + sol.file.name): + self.stdout.write(self.style.WARNING(("Warning: solution '{sol}' is not found. Maybe the file" + "was deleted?").format(sol=str(sol)))) + continue + copyfile('media/' + sol.file.name, teams_dir + '/' + tournament.name + + '/' + str(sol.team) + '/' + str(sol) + '.pdf') + copied += 1 + + self.stdout.write(self.style.SUCCESS("Successfully copied {copied} solutions!".format(copied=copied))) + + os.mkdir(d + '/Par problème') + + for pb in range(1, 9): + sols = Solution.objects.filter(problem=pb).all() + pbdir = d + '/Par problème/Problème n°{number} — {problem}'.format(number=pb, problem=self.PROBLEMS[pb - 1]) + os.mkdir(pbdir) + for sol in sols: + os.symlink('../../Par équipe/' + sol.tournament.name + '/' + str(sol.team) + '/' + str(sol) + '.pdf', + pbdir + '/' + str(sol) + '.pdf') + + self.stdout.write(self.style.SUCCESS("Symlinks by problem created!")) + + urlretrieve('https://tfjm.org/wp-content/uploads/2020/01/Problemes2020_23_01_v1_1.pdf', d + '/Énoncés.pdf') + + self.stdout.write(self.style.SUCCESS("Questions retrieved!")) diff --git a/apps/member/management/commands/import_olddb.py b/apps/member/management/commands/import_olddb.py new file mode 100644 index 0000000..d3ff94f --- /dev/null +++ b/apps/member/management/commands/import_olddb.py @@ -0,0 +1,309 @@ +import os + +from django.core.management import BaseCommand, CommandError +from django.db import transaction +from member.models import TFJMUser, Document, Solution, Synthesis, Authorization, MotivationLetter +from tournament.models import Team, Tournament + + +class Command(BaseCommand): + """ + Import the old database. + Tables must be found into the import_olddb folder, as CSV files. + """ + + def add_arguments(self, parser): + parser.add_argument('--tournaments', '-t', action="store", help="Import tournaments") + parser.add_argument('--teams', '-T', action="store", help="Import teams") + parser.add_argument('--users', '-u', action="store", help="Import users") + parser.add_argument('--documents', '-d', action="store", help="Import all documents") + + def handle(self, *args, **options): + if "tournaments" in options: + self.import_tournaments() + + if "teams" in options: + self.import_teams() + + if "users" in options: + self.import_users() + + if "documents" in options: + self.import_documents() + + @transaction.atomic + def import_tournaments(self): + """ + Import tournaments into the new database. + """ + print("Importing tournaments...") + with open("import_olddb/tournaments.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if Tournament.objects.filter(pk=args[0]).exists(): + continue + + obj_dict = { + "id": args[0], + "name": args[1], + "size": args[2], + "place": args[3], + "price": args[4], + "description": args[5], + "date_start": args[6], + "date_end": args[7], + "date_inscription": args[8], + "date_solutions": args[9], + "date_syntheses": args[10], + "date_solutions_2": args[11], + "date_syntheses_2": args[12], + "final": args[13], + "year": args[14], + } + with transaction.atomic(): + Tournament.objects.create(**obj_dict) + print(self.style.SUCCESS("Tournaments imported")) + + @staticmethod + def validation_status(status): + if status == "NOT_READY": + return "0invalid" + elif status == "WAITING": + return "1waiting" + elif status == "VALIDATED": + return "2valid" + else: + raise CommandError("Unknown status: {}".format(status)) + + @transaction.atomic + def import_teams(self): + """ + Import teams into new database. + """ + self.stdout.write("Importing teams...") + with open("import_olddb/teams.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if Team.objects.filter(pk=args[0]).exists(): + continue + + obj_dict = { + "id": args[0], + "name": args[1], + "trigram": args[2], + "tournament": Tournament.objects.get(pk=args[3]), + "inscription_date": args[13], + "validation_status": Command.validation_status(args[14]), + "selected_for_final": args[15], + "access_code": args[16], + "year": args[17], + } + with transaction.atomic(): + Team.objects.create(**obj_dict) + print(self.style.SUCCESS("Teams imported")) + + @staticmethod + def role(role): + if role == "ADMIN": + return "0admin" + elif role == "ORGANIZER": + return "1volunteer" + elif role == "ENCADRANT": + return "2coach" + elif role == "PARTICIPANT": + return "3participant" + else: + raise CommandError("Unknown role: {}".format(role)) + + @transaction.atomic + def import_users(self): + """ + Import users into the new database. + :return: + """ + self.stdout.write("Importing users...") + with open("import_olddb/users.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if TFJMUser.objects.filter(pk=args[0]).exists(): + continue + + obj_dict = { + "id": args[0], + "email": args[1], + "username": args[1], + "password": "bcrypt$" + args[2], + "last_name": args[3], + "first_name": args[4], + "birth_date": args[5], + "gender": "male" if args[6] == "M" else "female", + "address": args[7], + "postal_code": args[8], + "city": args[9], + "country": args[10], + "phone_number": args[11], + "school": args[12], + "student_class": args[13].lower().replace('premiere', 'première') if args[13] else None, + "responsible_name": args[14], + "responsible_phone": args[15], + "responsible_email": args[16], + "description": args[17].replace("\\n", "\n") if args[17] else None, + "role": Command.role(args[18]), + "team": Team.objects.get(pk=args[19]) if args[19] else None, + "year": args[20], + "date_joined": args[23], + "is_active": args[18] == "ADMIN" or os.getenv("TFJM_STAGE", "dev") == "prod", + "is_staff": args[18] == "ADMIN", + "is_superuser": args[18] == "ADMIN", + } + with transaction.atomic(): + TFJMUser.objects.create(**obj_dict) + self.stdout.write(self.style.SUCCESS("Users imported")) + + self.stdout.write("Importing organizers...") + # We also import the information about the organizers of a tournament. + with open("import_olddb/organizers.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + with transaction.atomic(): + tournament = Tournament.objects.get(pk=args[2]) + organizer = TFJMUser.objects.get(pk=args[1]) + tournament.organizers.add(organizer) + tournament.save() + self.stdout.write(self.style.SUCCESS("Organizers imported")) + + @transaction.atomic + def import_documents(self): + """ + Import the documents (authorizations, motivation letters, solutions, syntheses) from the old database. + """ + self.stdout.write("Importing documents...") + with open("import_olddb/documents.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if Document.objects.filter(file=args[0]).exists(): + doc = Document.objects.get(file=args[0]) + doc.uploaded_at = args[5].replace(" ", "T") + doc.save() + continue + + obj_dict = { + "file": args[0], + "uploaded_at": args[5], + } + if args[4] != "MOTIVATION_LETTER": + obj_dict["user"] = TFJMUser.objects.get(args[1]), + obj_dict["type"] = args[4].lower() + else: + try: + obj_dict["team"] = Team.objects.get(pk=args[2]) + except Team.DoesNotExist: + print("Team with pk {} does not exist, ignoring".format(args[2])) + continue + with transaction.atomic(): + if args[4] != "MOTIVATION_LETTER": + Authorization.objects.create(**obj_dict) + else: + MotivationLetter.objects.create(**obj_dict) + self.stdout.write(self.style.SUCCESS("Authorizations imported")) + + with open("import_olddb/solutions.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if Document.objects.filter(file=args[0]).exists(): + doc = Document.objects.get(file=args[0]) + doc.uploaded_at = args[4].replace(" ", "T") + doc.save() + continue + + obj_dict = { + "file": args[0], + "team": Team.objects.get(pk=args[1]), + "problem": args[3], + "uploaded_at": args[4], + } + with transaction.atomic(): + try: + Solution.objects.create(**obj_dict) + except: + print("Solution exists") + self.stdout.write(self.style.SUCCESS("Solutions imported")) + + with open("import_olddb/syntheses.csv") as f: + first_line = True + for line in f: + if first_line: + first_line = False + continue + + line = line[:-1].replace("\"", "") + args = line.split(";") + args = [arg if arg and arg != "NULL" else None for arg in args] + + if Document.objects.filter(file=args[0]).exists(): + doc = Document.objects.get(file=args[0]) + doc.uploaded_at = args[5].replace(" ", "T") + doc.save() + continue + + obj_dict = { + "file": args[0], + "team": Team.objects.get(pk=args[1]), + "source": "opponent" if args[3] == "1" else "rapporteur", + "round": args[4], + "uploaded_at": args[5], + } + with transaction.atomic(): + try: + Synthesis.objects.create(**obj_dict) + except: + print("Synthesis exists") + self.stdout.write(self.style.SUCCESS("Syntheses imported")) diff --git a/apps/member/migrations/__init__.py b/apps/member/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/member/models.py b/apps/member/models.py new file mode 100644 index 0000000..82da7dd --- /dev/null +++ b/apps/member/models.py @@ -0,0 +1,368 @@ +import os +from datetime import date + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from polymorphic.models import PolymorphicModel + +from tournament.models import Team, Tournament + + +class TFJMUser(AbstractUser): + """ + The model of registered users (organizers/juries/admins/coachs/participants) + """ + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + + email = models.EmailField( + unique=True, + verbose_name=_("email"), + help_text=_("This should be valid and will be controlled."), + ) + + team = models.ForeignKey( + Team, + null=True, + on_delete=models.SET_NULL, + related_name="users", + verbose_name=_("team"), + help_text=_("Concerns only coaches and participants."), + ) + + birth_date = models.DateField( + null=True, + default=None, + verbose_name=_("birth date"), + ) + + gender = models.CharField( + max_length=16, + null=True, + default=None, + choices=[ + ("male", _("Male")), + ("female", _("Female")), + ("non-binary", _("Non binary")), + ], + verbose_name=_("gender"), + ) + + address = models.CharField( + max_length=255, + null=True, + default=None, + verbose_name=_("address"), + ) + + postal_code = models.PositiveIntegerField( + null=True, + default=None, + verbose_name=_("postal code"), + ) + + city = models.CharField( + max_length=255, + null=True, + default=None, + verbose_name=_("city"), + ) + + country = models.CharField( + max_length=255, + default="France", + null=True, + verbose_name=_("country"), + ) + + phone_number = models.CharField( + max_length=20, + null=True, + blank=True, + default=None, + verbose_name=_("phone number"), + ) + + school = models.CharField( + max_length=255, + null=True, + default=None, + verbose_name=_("school"), + ) + + student_class = models.CharField( + max_length=16, + choices=[ + ('seconde', _("Seconde or less")), + ('première', _("Première")), + ('terminale', _("Terminale")), + ], + null=True, + default=None, + verbose_name="class", + ) + + responsible_name = models.CharField( + max_length=255, + null=True, + default=None, + verbose_name=_("responsible name"), + ) + + responsible_phone = models.CharField( + max_length=20, + null=True, + default=None, + verbose_name=_("responsible phone"), + ) + + responsible_email = models.EmailField( + null=True, + default=None, + verbose_name=_("responsible email"), + ) + + description = models.TextField( + null=True, + default=None, + verbose_name=_("description"), + ) + + role = models.CharField( + max_length=16, + choices=[ + ("0admin", _("Admin")), + ("1volunteer", _("Organizer")), + ("2coach", _("Coach")), + ("3participant", _("Participant")), + ] + ) + + year = models.PositiveIntegerField( + default=os.getenv("TFJM_YEAR", date.today().year), + verbose_name=_("year"), + ) + + @property + def participates(self): + """ + Return True iff this user is a participant or a coach, ie. if the user is a member of a team that worked + for the tournament. + """ + return self.role == "3participant" or self.role == "2coach" + + @property + def organizes(self): + """ + Return True iff this user is a local or global organizer of the tournament. This includes juries. + """ + return self.role == "1volunteer" or self.role == "0admin" + + @property + def admin(self): + """ + Return True iff this user is a global organizer, ie. an administrator. This should be equivalent to be + a superuser. + """ + return self.role == "0admin" + + class Meta: + verbose_name = _("user") + verbose_name_plural = _("users") + + def save(self, *args, **kwargs): + # We ensure that the username is the email of the user. + self.username = self.email + super().save(*args, **kwargs) + + def __str__(self): + return self.first_name + " " + self.last_name + + +class Document(PolymorphicModel): + """ + Abstract model of any saved document (solution, synthesis, motivation letter, authorization) + """ + file = models.FileField( + unique=True, + verbose_name=_("file"), + ) + + uploaded_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_("uploaded at"), + ) + + class Meta: + verbose_name = _("document") + verbose_name_plural = _("documents") + + def delete(self, *args, **kwargs): + self.file.delete(True) + return super().delete(*args, **kwargs) + + +class Authorization(Document): + """ + Model for authorization papers (parental consent, photo consent, sanitary plug, ...) + """ + user = models.ForeignKey( + TFJMUser, + on_delete=models.CASCADE, + related_name="authorizations", + verbose_name=_("user"), + ) + + type = models.CharField( + max_length=32, + choices=[ + ("parental_consent", _("Parental consent")), + ("photo_consent", _("Photo consent")), + ("sanitary_plug", _("Sanitary plug")), + ("scholarship", _("Scholarship")), + ], + verbose_name=_("type"), + ) + + class Meta: + verbose_name = _("authorization") + verbose_name_plural = _("authorizations") + + def __str__(self): + return _("{authorization} for user {user}").format(authorization=self.type, user=str(self.user)) + + +class MotivationLetter(Document): + """ + Model for motivation letters of a team. + """ + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="motivation_letters", + verbose_name=_("team"), + ) + + class Meta: + verbose_name = _("motivation letter") + verbose_name_plural = _("motivation letters") + + def __str__(self): + return _("Motivation letter of team {team} ({trigram})").format(team=self.team.name, trigram=self.team.trigram) + + +class Solution(Document): + """ + Model for solutions of team for a given problem, for the regional or final tournament. + """ + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="solutions", + verbose_name=_("team"), + ) + + problem = models.PositiveSmallIntegerField( + verbose_name=_("problem"), + ) + + final = models.BooleanField( + default=False, + verbose_name=_("final solution"), + ) + + @property + def tournament(self): + """ + Get the concerned tournament of a solution. + Generally the local tournament of a team, but it can be the final tournament if this is a solution for the + final tournament. + """ + return Tournament.get_final() if self.final else self.team.tournament + + class Meta: + verbose_name = _("solution") + verbose_name_plural = _("solutions") + unique_together = ('team', 'problem', 'final',) + + def __str__(self): + if self.final: + return _("Solution of team {trigram} for problem {problem} for final")\ + .format(trigram=self.team.trigram, problem=self.problem) + else: + return _("Solution of team {trigram} for problem {problem}")\ + .format(trigram=self.team.trigram, problem=self.problem) + + +class Synthesis(Document): + """ + Model for syntheses of a team for a given round and for a given role, for the regional or final tournament. + """ + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="syntheses", + verbose_name=_("team"), + ) + + source = models.CharField( + max_length=16, + choices=[ + ("opponent", _("Opponent")), + ("rapporteur", _("Rapporteur")), + ], + verbose_name=_("source"), + ) + + round = models.PositiveSmallIntegerField( + choices=[ + (1, _("Round 1")), + (2, _("Round 2")), + ], + verbose_name=_("round"), + ) + + final = models.BooleanField( + default=False, + verbose_name=_("final synthesis"), + ) + + @property + def tournament(self): + """ + Get the concerned tournament of a solution. + Generally the local tournament of a team, but it can be the final tournament if this is a solution for the + final tournament. + """ + return Tournament.get_final() if self.final else self.team.tournament + + class Meta: + verbose_name = _("synthesis") + verbose_name_plural = _("syntheses") + unique_together = ('team', 'source', 'round', 'final',) + + def __str__(self): + return _("Synthesis of team {trigram} that is {source} for the round {round} of tournament {tournament}")\ + .format(trigram=self.team.trigram, source=self.get_source_display().lower(), round=self.round, + tournament=self.tournament) + + +class Config(models.Model): + """ + Dictionary of configuration variables. + """ + key = models.CharField( + max_length=255, + primary_key=True, + verbose_name=_("key"), + ) + + value = models.TextField( + default="", + verbose_name=_("value"), + ) + + class Meta: + verbose_name = _("configuration") + verbose_name_plural = _("configurations") diff --git a/apps/member/tables.py b/apps/member/tables.py new file mode 100644 index 0000000..779dc47 --- /dev/null +++ b/apps/member/tables.py @@ -0,0 +1,26 @@ +import django_tables2 as tables +from django_tables2 import A + +from .models import TFJMUser + + +class UserTable(tables.Table): + """ + Table of users that are matched with a given queryset. + """ + last_name = tables.LinkColumn( + "member:information", + args=[A("pk")], + ) + + first_name = tables.LinkColumn( + "member:information", + args=[A("pk")], + ) + + class Meta: + model = TFJMUser + fields = ("last_name", "first_name", "role", "date_joined", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } diff --git a/apps/member/templatetags/__init__.py b/apps/member/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/member/templatetags/getconfig.py b/apps/member/templatetags/getconfig.py new file mode 100644 index 0000000..0c6d776 --- /dev/null +++ b/apps/member/templatetags/getconfig.py @@ -0,0 +1,25 @@ +from django import template + +import os + +from member.models import Config + + +def get_config(value): + """ + Return a value stored into the config table in the database with a given key. + """ + config = Config.objects.get_or_create(key=value)[0] + return config.value + + +def get_env(value): + """ + Get a specified environment variable. + """ + return os.getenv(value) + + +register = template.Library() +register.filter('get_config', get_config) +register.filter('get_env', get_env) diff --git a/apps/member/urls.py b/apps/member/urls.py new file mode 100644 index 0000000..073f057 --- /dev/null +++ b/apps/member/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import CreateUserView, MyAccountView, UserDetailView, AddTeamView, JoinTeamView, MyTeamView,\ + ProfileListView, OrphanedProfileListView, OrganizersListView, ResetAdminView + +app_name = "member" + +urlpatterns = [ + path('signup/', CreateUserView.as_view(), name="signup"), + path("my-account/", MyAccountView.as_view(), name="my_account"), + path("information//", UserDetailView.as_view(), name="information"), + path("add-team/", AddTeamView.as_view(), name="add_team"), + path("join-team/", JoinTeamView.as_view(), name="join_team"), + path("my-team/", MyTeamView.as_view(), name="my_team"), + path("profiles/", ProfileListView.as_view(), name="all_profiles"), + path("orphaned-profiles/", OrphanedProfileListView.as_view(), name="orphaned_profiles"), + path("organizers/", OrganizersListView.as_view(), name="organizers"), + path("reset-admin/", ResetAdminView.as_view(), name="reset_admin"), +] diff --git a/apps/member/views.py b/apps/member/views.py new file mode 100644 index 0000000..cebde40 --- /dev/null +++ b/apps/member/views.py @@ -0,0 +1,292 @@ +import random + +from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.db.models import Q +from django.http import FileResponse, Http404 +from django.shortcuts import redirect +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import CreateView, UpdateView, DetailView, FormView +from django_tables2 import SingleTableView +from tournament.forms import TeamForm, JoinTeam +from tournament.models import Team, Tournament, Pool +from tournament.views import AdminMixin, TeamMixin, OrgaMixin + +from .forms import SignUpForm, TFJMUserForm, AdminUserForm, CoachUserForm +from .models import TFJMUser, Document, Solution, MotivationLetter, Synthesis +from .tables import UserTable + + +class CreateUserView(CreateView): + """ + Signup form view. + """ + model = TFJMUser + form_class = SignUpForm + template_name = "registration/signup.html" + + # When errors are reported from the signup view, don't send passwords to admins + @method_decorator(sensitive_post_parameters('password1', 'password2',)) + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse_lazy('index') + + +class MyAccountView(LoginRequiredMixin, UpdateView): + """ + Update our personal data. + """ + model = TFJMUser + template_name = "member/my_account.html" + + def get_form_class(self): + # The used form can change according to the role of the user. + return AdminUserForm if self.request.user.organizes else TFJMUserForm \ + if self.request.user.role == "3participant" else CoachUserForm + + def get_object(self, queryset=None): + return self.request.user + + def get_success_url(self): + return reverse_lazy('member:my_account') + + +class UserDetailView(LoginRequiredMixin, DetailView): + """ + View the personal information of a given user. + Only organizers can see this page, since there are personal data. + """ + model = TFJMUser + form_class = TFJMUserForm + context_object_name = "tfjmuser" + + def dispatch(self, request, *args, **kwargs): + if isinstance(request.user, AnonymousUser): + raise PermissionDenied + + self.object = self.get_object() + + if not request.user.admin \ + and (self.object.team is not None and request.user not in self.object.team.tournament.organizers.all())\ + and (self.object.team is not None and self.object.team.selected_for_final + and request.user not in Tournament.get_final().organizers.all())\ + and self.request.user != self.object: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """ + An administrator can log in through this page as someone else, and act as this other person. + """ + if "view_as" in request.POST and self.request.user.admin: + session = request.session + session["admin"] = request.user.pk + obj = self.get_object() + session["_fake_user_id"] = obj.pk + return redirect(request.path) + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["title"] = str(self.object) + + return context + + +class AddTeamView(LoginRequiredMixin, CreateView): + """ + Register a new team. + Users can choose the name, the trigram and a preferred tournament. + """ + model = Team + form_class = TeamForm + + def form_valid(self, form): + if self.request.user.organizes: + form.add_error('name', _("You can't organize and participate at the same time.")) + return self.form_invalid(form) + + if self.request.user.team: + form.add_error('name', _("You are already in a team.")) + return self.form_invalid(form) + + # Generate a random access code + team = form.instance + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" + code = "" + for i in range(6): + code += random.choice(alphabet) + team.access_code = code + team.validation_status = "0invalid" + + team.save() + team.refresh_from_db() + + self.request.user.team = team + self.request.user.save() + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("member:my_team") + + +class JoinTeamView(LoginRequiredMixin, FormView): + """ + Join a team with a given access code. + """ + model = Team + form_class = JoinTeam + template_name = "tournament/team_form.html" + + def form_valid(self, form): + team = form.cleaned_data["team"] + + if self.request.user.organizes: + form.add_error('access_code', _("You can't organize and participate at the same time.")) + return self.form_invalid(form) + + if self.request.user.team: + form.add_error('access_code', _("You are already in a team.")) + return self.form_invalid(form) + + if self.request.user.role == '2coach' and len(team.coaches) == 3: + form.add_error('access_code', _("This team is full of coachs.")) + return self.form_invalid(form) + + if self.request.user.role == '3participant' and len(team.participants) == 6: + form.add_error('access_code', _("This team is full of participants.")) + return self.form_invalid(form) + + if not team.invalid: + form.add_error('access_code', _("This team is already validated or waiting for validation.")) + + self.request.user.team = team + self.request.user.save() + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("member:my_team") + + +class MyTeamView(TeamMixin, View): + """ + Redirect to the page of the information of our personal team. + """ + + def get(self, request, *args, **kwargs): + return redirect("tournament:team_detail", pk=request.user.team.pk) + + +class DocumentView(AccessMixin, View): + """ + View a PDF document, if we have the right. + + - Everyone can see the documents that concern itself. + - An administrator can see anything. + - An organizer can see documents that are related to its tournament. + - A jury can see solutions and syntheses that are evaluated in their pools. + """ + + def get(self, request, *args, **kwargs): + try: + doc = Document.objects.get(file=self.kwargs["file"]) + except Document.DoesNotExist: + raise Http404(_("No %(verbose_name)s found matching the query") % + {'verbose_name': Document._meta.verbose_name}) + + if request.user.is_authenticated: + grant = request.user.admin + + if isinstance(doc, Solution) or isinstance(doc, Synthesis): + grant = grant or doc.team == request.user.team or request.user in doc.tournament.organizers.all() + elif isinstance(doc, MotivationLetter): + grant = grant or doc.team == request.user.team or request.user in doc.team.tournament.organizers.all() + grant = grant or doc.team.selected_for_final and request.user in Tournament.get_final().organizers.all() + + if isinstance(doc, Solution): + for pool in doc.pools.all(): + if request.user in pool.juries.all(): + grant = True + break + if pool.round == 2 and timezone.now() < doc.tournament.date_solutions_2: + continue + if self.request.user.team in pool.teams.all(): + grant = True + elif isinstance(doc, Synthesis): + for pool in request.user.pools.all(): # If the user is a jury in the pool + if doc.team in pool.teams.all() and doc.final == pool.tournament.final: + grant = True + break + else: + pool = Pool.objects.filter(extra_access_token=self.request.session["extra_access_token"]) + if pool.exists(): + pool = pool.get() + if isinstance(doc, Solution): + grant = doc in pool.solutions.all() + elif isinstance(doc, Synthesis): + grant = doc.team in pool.teams.all() and doc.final == pool.tournament.final + else: + grant = False + else: + grant = False + + if not grant: + raise PermissionDenied + + return FileResponse(doc.file, content_type="application/pdf", filename=str(doc) + ".pdf") + + +class ProfileListView(AdminMixin, SingleTableView): + """ + List all registered profiles. + """ + model = TFJMUser + queryset = TFJMUser.objects.order_by("role", "last_name", "first_name") + table_class = UserTable + template_name = "member/profile_list.html" + extra_context = dict(title=_("All profiles"), type="all") + + +class OrphanedProfileListView(AdminMixin, SingleTableView): + """ + List all orphaned profiles, ie. participants that have no team. + """ + model = TFJMUser + queryset = TFJMUser.objects.filter((Q(role="2coach") | Q(role="3participant")) & Q(team__isnull=True))\ + .order_by("role", "last_name", "first_name") + table_class = UserTable + template_name = "member/profile_list.html" + extra_context = dict(title=_("Orphaned profiles"), type="orphaned") + + +class OrganizersListView(OrgaMixin, SingleTableView): + """ + List all organizers. + """ + model = TFJMUser + queryset = TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer"))\ + .order_by("role", "last_name", "first_name") + table_class = UserTable + template_name = "member/profile_list.html" + extra_context = dict(title=_("Organizers"), type="organizers") + + +class ResetAdminView(AdminMixin, View): + """ + Return to admin view, clear the session field that let an administrator to log in as someone else. + """ + + def dispatch(self, request, *args, **kwargs): + if "_fake_user_id" in request.session: + del request.session["_fake_user_id"] + return redirect(request.GET["path"]) diff --git a/apps/tournament/__init__.py b/apps/tournament/__init__.py new file mode 100644 index 0000000..9868b56 --- /dev/null +++ b/apps/tournament/__init__.py @@ -0,0 +1 @@ +default_app_config = 'tournament.apps.TournamentConfig' diff --git a/apps/tournament/admin.py b/apps/tournament/admin.py new file mode 100644 index 0000000..c55cc4b --- /dev/null +++ b/apps/tournament/admin.py @@ -0,0 +1,31 @@ +from django.contrib.auth.admin import admin + +from .models import Team, Tournament, Pool, Payment + + +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + """ + Django admin page for teams. + """ + + +@admin.register(Tournament) +class TournamentAdmin(admin.ModelAdmin): + """ + Django admin page for tournaments. + """ + + +@admin.register(Pool) +class PoolAdmin(admin.ModelAdmin): + """ + Django admin page for pools. + """ + + +@admin.register(Payment) +class PaymentAdmin(admin.ModelAdmin): + """ + Django admin page for payments. + """ diff --git a/apps/tournament/apps.py b/apps/tournament/apps.py new file mode 100644 index 0000000..66a212b --- /dev/null +++ b/apps/tournament/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class TournamentConfig(AppConfig): + """ + The tournament app handles all that is related to the tournaments. + """ + name = 'tournament' + verbose_name = _('tournament') diff --git a/apps/tournament/forms.py b/apps/tournament/forms.py new file mode 100644 index 0000000..901b97a --- /dev/null +++ b/apps/tournament/forms.py @@ -0,0 +1,262 @@ +import os +import re + +from django import forms +from django.db.models import Q +from django.template.defaultfilters import filesizeformat +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from member.models import TFJMUser, Solution, Synthesis +from tfjm.inputs import DatePickerInput, DateTimePickerInput, AmountInput +from tournament.models import Tournament, Team, Pool + + +class TournamentForm(forms.ModelForm): + """ + Create and update tournaments. + """ + + # Only organizers can organize tournaments. Well, that's pretty normal... + organizers = forms.ModelMultipleChoiceField( + TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer")).order_by('role'), + label=_("Organizers"), + ) + + def clean(self): + cleaned_data = super().clean() + + if not self.instance.pk: + if Tournament.objects.filter(name=cleaned_data["data"], year=os.getenv("TFJM_YEAR")): + self.add_error("name", _("This tournament already exists.")) + if cleaned_data["final"] and Tournament.objects.filter(final=True, year=os.getenv("TFJM_YEAR")): + self.add_error("name", _("The final tournament was already defined.")) + + return cleaned_data + + class Meta: + model = Tournament + exclude = ('year',) + widgets = { + "price": AmountInput(), + "date_start": DatePickerInput(), + "date_end": DatePickerInput(), + "date_inscription": DateTimePickerInput(), + "date_solutions": DateTimePickerInput(), + "date_syntheses": DateTimePickerInput(), + "date_solutions_2": DateTimePickerInput(), + "date_syntheses_2": DateTimePickerInput(), + } + + +class OrganizerForm(forms.ModelForm): + """ + Register an organizer in the website. + """ + + class Meta: + model = TFJMUser + fields = ('last_name', 'first_name', 'email', 'is_superuser',) + + def clean(self): + cleaned_data = super().clean() + + if TFJMUser.objects.filter(email=cleaned_data["email"], year=os.getenv("TFJM_YEAR")).exists(): + self.add_error("email", _("This organizer already exist.")) + + return cleaned_data + + def save(self, commit=True): + user = self.instance + user.role = '0admin' if user.is_superuser else '1volunteer' + user.save() + super().save(commit) + + +class TeamForm(forms.ModelForm): + """ + Add and update a team. + """ + tournament = forms.ModelChoiceField( + Tournament.objects.filter(date_inscription__gte=timezone.now(), final=False), + ) + + class Meta: + model = Team + fields = ('name', 'trigram', 'tournament',) + + def clean(self): + cleaned_data = super().clean() + + cleaned_data["trigram"] = cleaned_data["trigram"].upper() + + if not re.match("[A-Z]{3}", cleaned_data["trigram"]): + self.add_error("trigram", _("The trigram must be composed of three upcase letters.")) + + if not self.instance.pk: + if Team.objects.filter(trigram=cleaned_data["trigram"], year=os.getenv("TFJM_YEAR")).exists(): + self.add_error("trigram", _("This trigram is already used.")) + + if Team.objects.filter(name=cleaned_data["name"], year=os.getenv("TFJM_YEAR")).exists(): + self.add_error("name", _("This name is already used.")) + + if cleaned_data["tournament"].date_inscription < timezone.now: + self.add_error("tournament", _("This tournament is already closed.")) + + return cleaned_data + + +class JoinTeam(forms.Form): + """ + Form to join a team with an access code. + """ + + access_code = forms.CharField( + label=_("Access code"), + max_length=6, + ) + + def clean(self): + cleaned_data = super().clean() + + if not re.match("[a-z0-9]{6}", cleaned_data["access_code"]): + self.add_error('access_code', _("The access code must be composed of 6 alphanumeric characters.")) + + team = Team.objects.filter(access_code=cleaned_data["access_code"]) + if not team.exists(): + self.add_error('access_code', _("This access code is invalid.")) + team = team.get() + if not team.invalid: + self.add_error('access_code', _("The team is already validated.")) + cleaned_data["team"] = team + + return cleaned_data + + +class SolutionForm(forms.ModelForm): + """ + Form to upload a solution. + """ + + problem = forms.ChoiceField( + label=_("Problem"), + choices=[(str(i), _("Problem #%(problem)d") % {"problem": i}) for i in range(1, 9)], + ) + + def clean_file(self): + content = self.cleaned_data['file'] + content_type = content.content_type + if content_type in ["application/pdf"]: + if content.size > 5 * 2 ** 20: + raise forms.ValidationError( + _('Please keep filesize under %(max_size)s. Current filesize %(current_size)s') % { + "max_size": filesizeformat(2 * 2 ** 20), + "current_size": filesizeformat(content.size) + }) + else: + raise forms.ValidationError(_('The file should be a PDF file.')) + return content + + class Meta: + model = Solution + fields = ('file', 'problem',) + + +class SynthesisForm(forms.ModelForm): + """ + Form to upload a synthesis. + """ + + def clean_file(self): + content = self.cleaned_data['file'] + content_type = content.content_type + if content_type in ["application/pdf"]: + if content.size > 5 * 2 ** 20: + raise forms.ValidationError( + _('Please keep filesize under %(max_size)s. Current filesize %(current_size)s') % { + "max_size": filesizeformat(2 * 2 ** 20), + "current_size": filesizeformat(content.size) + }) + else: + raise forms.ValidationError(_('The file should be a PDF file.')) + return content + + class Meta: + model = Synthesis + fields = ('file', 'source', 'round',) + + +class PoolForm(forms.ModelForm): + """ + Form to add a pool. + Should not be used: prefer to pass by API and auto-add pools with the results of the draw. + """ + + team1 = forms.ModelChoiceField( + Team.objects.filter(validation_status="2valid").all(), + empty_label=_("Choose a team..."), + label=_("Team 1"), + ) + + problem1 = forms.IntegerField( + min_value=1, + max_value=8, + initial=1, + label=_("Problem defended by team 1"), + ) + + team2 = forms.ModelChoiceField( + Team.objects.filter(validation_status="2valid").all(), + empty_label=_("Choose a team..."), + label=_("Team 2"), + ) + + problem2 = forms.IntegerField( + min_value=1, + max_value=8, + initial=2, + label=_("Problem defended by team 2"), + ) + + team3 = forms.ModelChoiceField( + Team.objects.filter(validation_status="2valid").all(), + empty_label=_("Choose a team..."), + label=_("Team 3"), + ) + + problem3 = forms.IntegerField( + min_value=1, + max_value=8, + initial=3, + label=_("Problem defended by team 3"), + ) + + def clean(self): + cleaned_data = super().clean() + + team1, pb1 = cleaned_data["team1"], cleaned_data["problem1"] + team2, pb2 = cleaned_data["team2"], cleaned_data["problem2"] + team3, pb3 = cleaned_data["team3"], cleaned_data["problem3"] + + sol1 = Solution.objects.get(team=team1, problem=pb1, final=team1.selected_for_final) + sol2 = Solution.objects.get(team=team2, problem=pb2, final=team2.selected_for_final) + sol3 = Solution.objects.get(team=team3, problem=pb3, final=team3.selected_for_final) + + cleaned_data["teams"] = [team1, team2, team3] + cleaned_data["solutions"] = [sol1, sol2, sol3] + + return cleaned_data + + def save(self, commit=True): + pool = super().save(commit) + + pool.refresh_from_db() + pool.teams.set(self.cleaned_data["teams"]) + pool.solutions.set(self.cleaned_data["solutions"]) + pool.save() + + return pool + + class Meta: + model = Pool + fields = ('round', 'juries',) diff --git a/apps/tournament/migrations/__init__.py b/apps/tournament/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tournament/models.py b/apps/tournament/models.py new file mode 100644 index 0000000..11d5886 --- /dev/null +++ b/apps/tournament/models.py @@ -0,0 +1,432 @@ +import os +import random + +from django.core.mail import send_mail +from django.db import models +from django.template.loader import render_to_string +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class Tournament(models.Model): + """ + Store the information of a tournament. + """ + + name = models.CharField( + max_length=255, + verbose_name=_("name"), + ) + + organizers = models.ManyToManyField( + 'member.TFJMUser', + related_name="organized_tournaments", + verbose_name=_("organizers"), + help_text=_("List of all organizers that can see and manipulate data of the tournament and the teams."), + ) + + size = models.PositiveSmallIntegerField( + verbose_name=_("size"), + help_text=_("Number of teams that are allowed to join the tournament."), + ) + + place = models.CharField( + max_length=255, + verbose_name=_("place"), + ) + + price = models.PositiveSmallIntegerField( + verbose_name=_("price"), + help_text=_("Price asked to participants. Free with a scholarship."), + ) + + description = models.TextField( + verbose_name=_("description"), + ) + + date_start = models.DateField( + default=timezone.now, + verbose_name=_("date start"), + ) + + date_end = models.DateField( + default=timezone.now, + verbose_name=_("date end"), + ) + + date_inscription = models.DateTimeField( + default=timezone.now, + verbose_name=_("date of registration closing"), + ) + + date_solutions = models.DateTimeField( + default=timezone.now, + verbose_name=_("date of maximal solution submission"), + ) + + date_syntheses = models.DateTimeField( + default=timezone.now, + verbose_name=_("date of maximal syntheses submission for the first round"), + ) + + date_solutions_2 = models.DateTimeField( + default=timezone.now, + verbose_name=_("date when solutions of round 2 are available"), + ) + + date_syntheses_2 = models.DateTimeField( + default=timezone.now, + verbose_name=_("date of maximal syntheses submission for the second round"), + ) + + final = models.BooleanField( + verbose_name=_("final tournament"), + help_text=_("It should be only one final tournament."), + ) + + year = models.PositiveIntegerField( + default=os.getenv("TFJM_YEAR", timezone.now().year), + verbose_name=_("year"), + ) + + @property + def teams(self): + """ + Get all teams that are registered to this tournament, with a distinction for the final tournament. + """ + return self._teams if not self.final else Team.objects.filter(selected_for_final=True) + + @property + def linked_organizers(self): + """ + Display a list of the organizers with links to their personal page. + """ + return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' + for user in self.organizers.all()] + + @property + def solutions(self): + """ + Get all sent solutions for this tournament. + """ + from member.models import Solution + return Solution.objects.filter(final=self.final) if self.final \ + else Solution.objects.filter(team__tournament=self, final=False) + + @property + def syntheses(self): + """ + Get all sent syntheses for this tournament. + """ + from member.models import Synthesis + return Synthesis.objects.filter(final=self.final) if self.final \ + else Synthesis.objects.filter(team__tournament=self, final=False) + + @classmethod + def get_final(cls): + """ + Get the final tournament. + This should exist and be unique. + """ + return cls.objects.get(year=os.getenv("TFJM_YEAR"), final=True) + + class Meta: + verbose_name = _("tournament") + verbose_name_plural = _("tournaments") + + def send_mail_to_organizers(self, template_name, subject="Contact TFJM²", **kwargs): + """ + Send a mail to all organizers of the tournament. + The template of the mail should be found either in templates/mail_templates/.html for the HTML + version and in templates/mail_templates/.txt for the plain text version. + The context of the template contains the tournament and the user. Extra context can be given through the kwargs. + """ + context = kwargs + context["tournament"] = self + for user in self.organizers.all(): + context["user"] = user + message = render_to_string("mail_templates/" + template_name + ".txt", context=context) + message_html = render_to_string("mail_templates/" + template_name + ".html", context=context) + send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html) + from member.models import TFJMUser + for user in TFJMUser.objects.get(is_superuser=True).all(): + context["user"] = user + message = render_to_string("mail_templates/" + template_name + ".txt", context=context) + message_html = render_to_string("mail_templates/" + template_name + ".html", context=context) + send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html) + + def __str__(self): + return self.name + + +class Team(models.Model): + """ + Store information about a registered team. + """ + + name = models.CharField( + max_length=255, + verbose_name=_("name"), + ) + + trigram = models.CharField( + max_length=3, + verbose_name=_("trigram"), + help_text=_("The trigram should be composed of 3 capitalize letters, that is a funny acronym for the team."), + ) + + tournament = models.ForeignKey( + Tournament, + on_delete=models.PROTECT, + related_name="_teams", + verbose_name=_("tournament"), + help_text=_("The tournament where the team is registered."), + ) + + inscription_date = models.DateTimeField( + auto_now_add=True, + verbose_name=_("inscription date"), + ) + + validation_status = models.CharField( + max_length=8, + choices=[ + ("0invalid", _("Registration not validated")), + ("1waiting", _("Waiting for validation")), + ("2valid", _("Registration validated")), + ], + verbose_name=_("validation status"), + ) + + selected_for_final = models.BooleanField( + default=False, + verbose_name=_("selected for final"), + ) + + access_code = models.CharField( + max_length=6, + unique=True, + verbose_name=_("access code"), + ) + + year = models.PositiveIntegerField( + default=os.getenv("TFJM_YEAR", timezone.now().year), + verbose_name=_("year"), + ) + + @property + def valid(self): + return self.validation_status == "2valid" + + @property + def waiting(self): + return self.validation_status == "1waiting" + + @property + def invalid(self): + return self.validation_status == "0invalid" + + @property + def coaches(self): + """ + Get all coaches of a team. + """ + return self.users.all().filter(role="2coach") + + @property + def linked_coaches(self): + """ + Get a list of the coaches of a team with html links to their pages. + """ + return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' + for user in self.coaches] + + @property + def participants(self): + """ + Get all particpants of a team, coaches excluded. + """ + return self.users.all().filter(role="3participant") + + @property + def linked_participants(self): + """ + Get a list of the participants of a team with html links to their pages. + """ + return [''.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '' + for user in self.participants] + + @property + def future_tournament(self): + """ + Get the last tournament where the team is registered. + Only matters if the team is selected for final: if this is the case, we return the final tournament. + Useful for deadlines. + """ + return Tournament.get_final() if self.selected_for_final else self.tournament + + @property + def can_validate(self): + """ + Check if a given team is able to ask for validation. + A team can validate if: + * All participants filled the photo consent + * Minor participants filled the parental consent + * Minor participants filled the sanitary plug + * Teams sent their motivation letter + * The team contains at least 4 participants + * The team contains at least 1 coach + """ + # TODO In a normal time, team needs a motivation letter and authorizations. + return self.coaches.exists() and self.participants.count() >= 4\ + and self.tournament.date_inscription <= timezone.now() + + class Meta: + verbose_name = _("team") + verbose_name_plural = _("teams") + unique_together = (('name', 'year',), ('trigram', 'year',),) + + def send_mail(self, template_name, subject="Contact TFJM²", **kwargs): + """ + Send a mail to all members of a team with a given template. + The template of the mail should be found either in templates/mail_templates/.html for the HTML + version and in templates/mail_templates/.txt for the plain text version. + The context of the template contains the team and the user. Extra context can be given through the kwargs. + """ + context = kwargs + context["team"] = self + for user in self.users.all(): + context["user"] = user + message = render_to_string("mail_templates/" + template_name + ".txt", context=context) + message_html = render_to_string("mail_templates/" + template_name + ".html", context=context) + send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html) + + def __str__(self): + return self.trigram + " — " + self.name + + +class Pool(models.Model): + """ + Store information of a pool. + A pool is only a list of accessible solutions to some teams and some juries. + TODO: check that the set of teams is equal to the set of the teams that have a solution in this set. + TODO: Moreover, a team should send only one solution. + """ + teams = models.ManyToManyField( + Team, + related_name="pools", + verbose_name=_("teams"), + ) + + solutions = models.ManyToManyField( + "member.Solution", + related_name="pools", + verbose_name=_("solutions"), + ) + + round = models.PositiveIntegerField( + choices=[ + (1, _("Round 1")), + (2, _("Round 2")), + ], + verbose_name=_("round"), + ) + + juries = models.ManyToManyField( + "member.TFJMUser", + related_name="pools", + verbose_name=_("juries"), + ) + + extra_access_token = models.CharField( + max_length=64, + default="", + verbose_name=_("extra access token"), + help_text=_("Let other users access to the pool data without logging in."), + ) + + @property + def problems(self): + """ + Get problem numbers of the sent solutions as a list of integers. + """ + return list(d["problem"] for d in self.solutions.values("problem").all()) + + @property + def tournament(self): + """ + Get the concerned tournament. + We assume that the pool is correct, so all solutions belong to the same tournament. + """ + return self.solutions.first().tournament + + @property + def syntheses(self): + """ + Get the syntheses of the teams that are in this pool, for the correct round. + """ + from member.models import Synthesis + return Synthesis.objects.filter(team__in=self.teams.all(), round=self.round, final=self.tournament.final) + + def save(self, **kwargs): + if not self.extra_access_token: + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" + code = "".join(random.choice(alphabet) for _ in range(64)) + self.extra_access_token = code + super().save(**kwargs) + + class Meta: + verbose_name = _("pool") + verbose_name_plural = _("pools") + + +class Payment(models.Model): + """ + Store some information about payments, to recover data. + TODO: handle it... + """ + user = models.OneToOneField( + 'member.TFJMUser', + on_delete=models.CASCADE, + related_name="payment", + verbose_name=_("user"), + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="payments", + verbose_name=_("team"), + ) + + method = models.CharField( + max_length=16, + choices=[ + ("not_paid", _("Not paid")), + ("credit_card", _("Credit card")), + ("check", _("Bank check")), + ("transfer", _("Bank transfer")), + ("cash", _("Cash")), + ("scholarship", _("Scholarship")), + ], + default="not_paid", + verbose_name=_("payment method"), + ) + + validation_status = models.CharField( + max_length=8, + choices=[ + ("0invalid", _("Registration not validated")), + ("1waiting", _("Waiting for validation")), + ("2valid", _("Registration validated")), + ], + verbose_name=_("validation status"), + ) + + class Meta: + verbose_name = _("payment") + verbose_name_plural = _("payments") + + def __str__(self): + return _("Payment of {user}").format(str(self.user)) diff --git a/apps/tournament/tables.py b/apps/tournament/tables.py new file mode 100644 index 0000000..33e39a1 --- /dev/null +++ b/apps/tournament/tables.py @@ -0,0 +1,164 @@ +import django_tables2 as tables +from django.urls import reverse_lazy +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django_tables2 import A + +from member.models import Solution, Synthesis +from .models import Tournament, Team, Pool + + +class TournamentTable(tables.Table): + """ + List all tournaments. + """ + + name = tables.LinkColumn( + "tournament:detail", + args=[A("pk")], + ) + + date_start = tables.Column( + verbose_name=_("dates").capitalize(), + ) + + def render_date_start(self, record): + return _("From {start:%b %d %Y} to {end:%b %d %Y}").format(start=record.date_start, end=record.date_end) + + class Meta: + model = Tournament + fields = ("name", "date_start", "date_inscription", "date_solutions", "size", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + order_by = ('date_start', 'name',) + + +class TeamTable(tables.Table): + """ + Table of some teams. Can be filtered with a queryset (for example, teams of a tournament) + """ + + name = tables.LinkColumn( + "tournament:team_detail", + args=[A("pk")], + ) + + class Meta: + model = Team + fields = ("name", "trigram", "validation_status", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + order_by = ('-validation_status', 'trigram',) + + +class SolutionTable(tables.Table): + """ + Display a table of some solutions. + """ + + team = tables.LinkColumn( + "tournament:team_detail", + args=[A("team.pk")], + ) + + tournament = tables.LinkColumn( + "tournament:detail", + args=[A("tournament.pk")], + accessor=A("tournament"), + order_by=("team__tournament__date_start", "team__tournament__name",), + verbose_name=_("Tournament"), + ) + + file = tables.LinkColumn( + "document", + args=[A("file")], + attrs={ + "a": { + "data-turbolinks": "false", + } + } + ) + + def render_file(self): + return _("Download") + + class Meta: + model = Solution + fields = ("team", "tournament", "problem", "uploaded_at", "file", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + + +class SynthesisTable(tables.Table): + """ + Display a table of some syntheses. + """ + + team = tables.LinkColumn( + "tournament:team_detail", + args=[A("team.pk")], + ) + + tournament = tables.LinkColumn( + "tournament:detail", + args=[A("tournament.pk")], + accessor=A("tournament"), + order_by=("team__tournament__date_start", "team__tournament__name",), + verbose_name=_("tournament"), + ) + + file = tables.LinkColumn( + "document", + args=[A("file")], + attrs={ + "a": { + "data-turbolinks": "false", + } + } + ) + + def render_file(self): + return _("Download") + + class Meta: + model = Synthesis + fields = ("team", "tournament", "round", "source", "uploaded_at", "file", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + + +class PoolTable(tables.Table): + """ + Display a table of some pools. + """ + + problems = tables.Column( + verbose_name=_("Problems"), + orderable=False, + ) + + tournament = tables.LinkColumn( + "tournament:detail", + args=[A("tournament.pk")], + verbose_name=_("Tournament"), + order_by=("teams__tournament__date_start", "teams__tournament__name",), + ) + + def render_teams(self, record, value): + return format_html('{trigrams}', + url=reverse_lazy('tournament:pool_detail', args=(record.pk,)), + trigrams=", ".join(team.trigram for team in value.all())) + + def render_problems(self, value): + return ", ".join([str(pb) for pb in value]) + + class Meta: + model = Pool + fields = ("teams", "tournament", "problems", "round", ) + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } diff --git a/apps/tournament/urls.py b/apps/tournament/urls.py new file mode 100644 index 0000000..364f23d --- /dev/null +++ b/apps/tournament/urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from .views import TournamentListView, TournamentCreateView, TournamentDetailView, TournamentUpdateView, \ + TeamDetailView, TeamUpdateView, AddOrganizerView, SolutionsView, SolutionsOrgaListView, SynthesesView, \ + SynthesesOrgaListView, PoolListView, PoolCreateView, PoolDetailView + +app_name = "tournament" + +urlpatterns = [ + path('list/', TournamentListView.as_view(), name="list"), + path("add/", TournamentCreateView.as_view(), name="add"), + path('/', TournamentDetailView.as_view(), name="detail"), + path('/update/', TournamentUpdateView.as_view(), name="update"), + path('team//', TeamDetailView.as_view(), name="team_detail"), + path('team//update/', TeamUpdateView.as_view(), name="team_update"), + path("add-organizer/", AddOrganizerView.as_view(), name="add_organizer"), + path("solutions/", SolutionsView.as_view(), name="solutions"), + path("all-solutions/", SolutionsOrgaListView.as_view(), name="all_solutions"), + path("syntheses/", SynthesesView.as_view(), name="syntheses"), + path("all_syntheses/", SynthesesOrgaListView.as_view(), name="all_syntheses"), + path("pools/", PoolListView.as_view(), name="pools"), + path("pool/add/", PoolCreateView.as_view(), name="create_pool"), + path("pool//", PoolDetailView.as_view(), name="pool_detail"), +] diff --git a/apps/tournament/views.py b/apps/tournament/views.py new file mode 100644 index 0000000..450351f --- /dev/null +++ b/apps/tournament/views.py @@ -0,0 +1,662 @@ +import random +import zipfile +from datetime import timedelta +from io import BytesIO + +from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin +from django.core.exceptions import PermissionDenied +from django.core.mail import send_mail +from django.db.models import Q +from django.http import HttpResponse +from django.shortcuts import redirect +from django.template.loader import render_to_string +from django.urls import reverse_lazy +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, CreateView, UpdateView +from django.views.generic.edit import BaseFormView +from django_tables2.views import SingleTableView +from member.models import TFJMUser, Solution, Synthesis + +from .forms import TournamentForm, OrganizerForm, SolutionForm, SynthesisForm, TeamForm, PoolForm +from .models import Tournament, Team, Pool +from .tables import TournamentTable, TeamTable, SolutionTable, SynthesisTable, PoolTable + + +class AdminMixin(LoginRequiredMixin): + """ + If a view extends this mixin, then the view will be only accessible to administrators. + """ + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated or not request.user.admin: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class OrgaMixin(AccessMixin): + """ + If a view extends this mixin, then the view will be only accessible to administrators or organizers. + """ + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated and not request.session["extra_access_token"]: + return self.handle_no_permission() + elif request.user.is_authenticated and not request.user.organizes: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class TeamMixin(LoginRequiredMixin): + """ + If a view extends this mixin, then the view will be only accessible to users that are registered in a team. + """ + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated or not request.user.team: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class TournamentListView(SingleTableView): + """ + Display the list of all tournaments, ordered by start date then name. + """ + + model = Tournament + table_class = TournamentTable + extra_context = dict(title=_("Tournaments list"),) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + team_users = TFJMUser.objects.filter(Q(team__isnull=False) | Q(role="admin") | Q(role="organizer"))\ + .order_by('-role') + valid_team_users = team_users.filter( + Q(team__validation_status="2valid") | Q(role="admin") | Q(role="organizer")) + + context["team_users_emails"] = [user.email for user in team_users] + context["valid_team_users_emails"] = [user.email for user in valid_team_users] + + return context + + +class TournamentCreateView(AdminMixin, CreateView): + """ + Create a tournament. Only accessible to admins. + """ + + model = Tournament + form_class = TournamentForm + extra_context = dict(title=_("Add tournament"),) + + def get_success_url(self): + return reverse_lazy('tournament:detail', args=(self.object.pk,)) + + +class TournamentDetailView(DetailView): + """ + Display the detail of a tournament. + Accessible to all, including not authenticated users. + """ + + model = Tournament + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["title"] = _("Tournament of {name}").format(name=self.object.name) + + if self.object.final: + team_users = TFJMUser.objects.filter(team__selected_for_final=True) + valid_team_users = team_users + else: + team_users = TFJMUser.objects.filter( + Q(team__tournament=self.object) + | Q(organized_tournaments=self.object)).order_by('role') + valid_team_users = team_users.filter( + Q(team__validation_status="2valid") + | Q(role="admin") + | Q(organized_tournaments=self.object)) + + context["team_users_emails"] = [user.email for user in team_users] + context["valid_team_users_emails"] = [user.email for user in valid_team_users] + + context["teams"] = TeamTable(self.object.teams.all()) + + return context + + +class TournamentUpdateView(OrgaMixin, UpdateView): + """ + Update the data of a tournament. + Reserved to admins and organizers of the tournament. + """ + + def dispatch(self, request, *args, **kwargs): + """ + Restrict the view to organizers of tournaments, then process the request. + """ + if self.request.user.role == "1volunteer" and self.request.user not in self.get_object().organizers.all(): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + model = Tournament + form_class = TournamentForm + extra_context = dict(title=_("Update tournament"),) + + def get_success_url(self): + return reverse_lazy('tournament:detail', args=(self.object.pk,)) + + +class TeamDetailView(LoginRequiredMixin, DetailView): + """ + View the detail of a team. + Restricted to this team, admins and organizers of its tournament. + """ + model = Team + + def dispatch(self, request, *args, **kwargs): + """ + Protect the page and process the request. + """ + if not request.user.is_authenticated or \ + (not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() + and not (self.get_object().selected_for_final + and request.user in Tournament.get_final().organizers.all()) + and self.get_object() != request.user.team): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """ + Process POST requests. Supported requests: + - get the solutions of the team as a ZIP archive + - a user leaves its team (if the composition is not validated yet) + - the team requests the validation + - Organizers can validate or invalidate the request + - Admins can delete teams + - Admins can select teams for the final tournament + """ + team = self.get_object() + if "zip" in request.POST: + solutions = team.solutions.all() + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for solution in solutions: + zf.write(solution.file.path, str(solution) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}'\ + .format(_("Solutions for team {team}.zip") + .format(team=str(team)).replace(" ", "%20")) + return resp + elif "leave" in request.POST and request.user.participates: + request.user.team = None + request.user.save() + if not team.users.exists(): + team.delete() + return redirect('tournament:detail', pk=team.tournament.pk) + elif "request_validation" in request.POST and request.user.participates and team.can_validate: + team.validation_status = "1waiting" + team.save() + team.tournament.send_mail_to_organizers("request_validation", "Demande de validation TFJM²", team=team) + return redirect('tournament:team_detail', pk=team.pk) + elif "validate" in request.POST and request.user.organizes: + team.validation_status = "2valid" + team.save() + team.send_mail("validate_team", "Équipe validée TFJM²") + return redirect('tournament:team_detail', pk=team.pk) + elif "invalidate" in request.POST and request.user.organizes: + team.validation_status = "0invalid" + team.save() + team.send_mail("unvalidate_team", "Équipe non validée TFJM²") + return redirect('tournament:team_detail', pk=team.pk) + elif "delete" in request.POST and request.user.organizes: + team.delete() + return redirect('tournament:detail', pk=team.tournament.pk) + elif "select_final" in request.POST and request.user.admin and not team.selected_for_final and team.pools: + # We copy all solutions for solutions for the final + for solution in team.solutions.all(): + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" + id = "" + for i in range(64): + id += random.choice(alphabet) + with solution.file.open("rb") as source: + with open("/code/media/" + id, "wb") as dest: + for chunk in source.chunks(): + dest.write(chunk) + new_sol = Solution( + file=id, + team=team, + problem=solution.problem, + final=True, + ) + new_sol.save() + team.selected_for_final = True + team.save() + team.send_mail("select_for_final", "Sélection pour la finale, félicitations ! - TFJM²", + final=Tournament.get_final()) + return redirect('tournament:team_detail', pk=team.pk) + + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["title"] = _("Information about team") + context["ordered_solutions"] = self.object.solutions.order_by('final', 'problem',).all() + context["team_users_emails"] = [user.email for user in self.object.users.all()] + + return context + + +class TeamUpdateView(LoginRequiredMixin, UpdateView): + """ + Update the information about a team. + Team members, admins and organizers are allowed to do this. + """ + + model = Team + form_class = TeamForm + extra_context = dict(title=_("Update team"),) + + def dispatch(self, request, *args, **kwargs): + if not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() \ + and self.get_object() != self.request.user.team: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class AddOrganizerView(AdminMixin, CreateView): + """ + Add a new organizer account. No password is created, the user should reset its password using the link + sent by mail. Only name and email are requested. + Only admins are granted to do this. + """ + + model = TFJMUser + form_class = OrganizerForm + extra_context = dict(title=_("Add organizer"),) + template_name = "tournament/add_organizer.html" + + def form_valid(self, form): + user = form.instance + msg = render_to_string("mail_templates/add_organizer.txt", context=dict(user=user)) + msg_html = render_to_string("mail_templates/add_organizer.html", context=dict(user=user)) + send_mail('Organisateur du TFJM² 2020', msg, 'contact@tfjm.org', [user.email], html_message=msg_html) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy('index') + + +class SolutionsView(TeamMixin, BaseFormView, SingleTableView): + """ + Upload and view solutions for a team. + """ + + model = Solution + table_class = SolutionTable + form_class = SolutionForm + template_name = "tournament/solutions_list.html" + extra_context = dict(title=_("Solutions")) + + def post(self, request, *args, **kwargs): + if "zip" in request.POST: + solutions = request.user.team.solutions + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for solution in solutions: + zf.write(solution.file.path, str(solution) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}'\ + .format(_("Solutions for team {team}.zip") + .format(team=str(request.user.team)).replace(" ", "%20")) + return resp + + return super().post(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + self.object_list = self.get_queryset() + context = super().get_context_data(**kwargs) + context["now"] = timezone.now() + context["real_deadline"] = self.request.user.team.future_tournament.date_solutions + timedelta(minutes=30) + return context + + def get_queryset(self): + qs = super().get_queryset().filter(team=self.request.user.team) + return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram', + 'problem',) + + def form_valid(self, form): + solution = form.instance + solution.team = self.request.user.team + solution.final = solution.team.selected_for_final + + if timezone.now() > solution.tournament.date_solutions + timedelta(minutes=30): + form.add_error('file', _("You can't publish your solution anymore. Deadline: {date:%m-%d-%Y %H:%M}.") + .format(date=timezone.localtime(solution.tournament.date_solutions))) + return super().form_invalid(form) + + prev_sol = Solution.objects.filter(problem=solution.problem, team=solution.team, final=solution.final) + for sol in prev_sol.all(): + sol.delete() + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" + id = "" + for i in range(64): + id += random.choice(alphabet) + solution.file.name = id + solution.save() + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("tournament:solutions") + + +class SolutionsOrgaListView(OrgaMixin, SingleTableView): + """ + View all solutions sent by teams for the organized tournaments. Juries can view solutions of their pools. + Organizers can download a ZIP archive for each organized tournament. + """ + + model = Solution + table_class = SolutionTable + template_name = "tournament/solutions_orga_list.html" + extra_context = dict(title=_("All solutions")) + + def post(self, request, *args, **kwargs): + if "tournament_zip" in request.POST: + tournament = Tournament.objects.get(pk=int(request.POST["tournament_zip"])) + solutions = tournament.solutions + if not request.user.admin and request.user not in tournament.organizers.all(): + raise PermissionDenied + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for solution in solutions: + zf.write(solution.file.path, str(solution) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}'\ + .format(_("Solutions for tournament {tournament}.zip") + .format(tournament=str(tournament)).replace(" ", "%20")) + return resp + + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.request.user.is_authenticated: + context["tournaments"] = \ + Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments + + return context + + def get_queryset(self): + qs = super().get_queryset() + if self.request.user.is_authenticated and not self.request.user.admin: + if self.request.user in Tournament.get_final().organizers.all(): + qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user) + | Q(final=True)) + else: + qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user)) + elif not self.request.user.is_authenticated: + qs = qs.filter(pools__extra_access_token=self.request.session["extra_access_token"]) + return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram', + 'problem',).distinct() + + +class SynthesesView(TeamMixin, BaseFormView, SingleTableView): + """ + Upload and view syntheses for a team. + """ + model = Synthesis + table_class = SynthesisTable + form_class = SynthesisForm + template_name = "tournament/syntheses_list.html" + extra_context = dict(title=_("Syntheses")) + + def post(self, request, *args, **kwargs): + if "zip" in request.POST: + syntheses = request.user.team.syntheses + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for synthesis in syntheses: + zf.write(synthesis.file.path, str(synthesis) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}'\ + .format(_("Syntheses for team {team}.zip") + .format(team=str(request.user.team)).replace(" ", "%20")) + return resp + + return super().post(request, *args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset().filter(team=self.request.user.team) + return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram', + 'round', 'source',) + + def get_context_data(self, **kwargs): + self.object_list = self.get_queryset() + context = super().get_context_data(**kwargs) + context["now"] = timezone.now() + context["real_deadline_1"] = self.request.user.team.future_tournament.date_syntheses + timedelta(minutes=30) + context["real_deadline_2"] = self.request.user.team.future_tournament.date_syntheses_2 + timedelta(minutes=30) + return context + + def form_valid(self, form): + synthesis = form.instance + synthesis.team = self.request.user.team + synthesis.final = synthesis.team.selected_for_final + + if synthesis.round == '1' and timezone.now() > (synthesis.tournament.date_syntheses + timedelta(minutes=30)): + form.add_error('file', _("You can't publish your synthesis anymore for the first round." + " Deadline: {date:%m-%d-%Y %H:%M}.") + .format(date=timezone.localtime(synthesis.tournament.date_syntheses))) + return super().form_invalid(form) + + if synthesis.round == '2' and timezone.now() > synthesis.tournament.date_syntheses_2 + timedelta(minutes=30): + form.add_error('file', _("You can't publish your synthesis anymore for the second round." + " Deadline: {date:%m-%d-%Y %H:%M}.") + .format(date=timezone.localtime(synthesis.tournament.date_syntheses_2))) + return super().form_invalid(form) + + prev_syn = Synthesis.objects.filter(team=synthesis.team, round=synthesis.round, source=synthesis.source, + final=synthesis.final) + for syn in prev_syn.all(): + syn.delete() + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789" + id = "" + for i in range(64): + id += random.choice(alphabet) + synthesis.file.name = id + synthesis.save() + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("tournament:syntheses") + + +class SynthesesOrgaListView(OrgaMixin, SingleTableView): + """ + View all syntheses sent by teams for the organized tournaments. Juries can view syntheses of their pools. + Organizers can download a ZIP archive for each organized tournament. + """ + model = Synthesis + table_class = SynthesisTable + template_name = "tournament/syntheses_orga_list.html" + extra_context = dict(title=_("All syntheses")) + + def post(self, request, *args, **kwargs): + if "tournament_zip" in request.POST: + tournament = Tournament.objects.get(pk=request.POST["tournament_zip"]) + syntheses = tournament.syntheses + if not request.user.admin and request.user not in tournament.organizers.all(): + raise PermissionDenied + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for synthesis in syntheses: + zf.write(synthesis.file.path, str(synthesis) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}'\ + .format(_("Syntheses for tournament {tournament}.zip") + .format(tournament=str(tournament)).replace(" ", "%20")) + return resp + + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.request.user.is_authenticated: + context["tournaments"] = \ + Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments + + return context + + def get_queryset(self): + qs = super().get_queryset() + if self.request.user.is_authenticated and not self.request.user.admin: + if self.request.user in Tournament.get_final().organizers.all(): + qs = qs.filter(Q(team__tournament__organizers=self.request.user) + | Q(team__pools__juries=self.request.user) + | Q(final=True)) + else: + qs = qs.filter(Q(team__tournament__organizers=self.request.user) + | Q(team__pools__juries=self.request.user)) + elif not self.request.user.is_authenticated: + pool = Pool.objects.filter(extra_access_token=self.request.session["extra_access_token"]) + if pool.exists(): + pool = pool.get() + qs = qs.filter(team__pools=pool, final=pool.tournament.final) + else: + qs = qs.none() + return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram', + 'round', 'source',).distinct() + + +class PoolListView(SingleTableView): + """ + View the list of visible pools. + Admins see all, juries see their own pools, organizers see the pools of their tournaments. + """ + model = Pool + table_class = PoolTable + extra_context = dict(title=_("Pools")) + + def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + if user.is_authenticated: + if not user.admin and user.organizes: + qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user)) + elif user.participates: + qs = qs.filter(teams=user.team) + else: + qs = qs.filter(extra_access_token=self.request.session["extra_access_token"]) + qs = qs.distinct().order_by('id') + return qs + + +class PoolCreateView(AdminMixin, CreateView): + """ + Create a pool manually. + This page should not be used: prefer send automatically data from the drawing bot. + """ + model = Pool + form_class = PoolForm + extra_context = dict(title=_("Create pool")) + + def get_success_url(self): + return reverse_lazy("tournament:pools") + + +class PoolDetailView(DetailView): + """ + See the detail of a pool. + Teams and juries can download here defended solutions of the pool. + If this is the second round, teams can't download solutions of the other teams before the date when they + should be available. + Juries see also syntheses. They see of course solutions immediately. + This is also true for organizers and admins. + All can be downloaded as a ZIP archive. + """ + model = Pool + extra_context = dict(title=_("Pool detail")) + + def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + if user.is_authenticated: + if not user.admin and user.organizes: + qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user)) + elif user.participates: + qs = qs.filter(teams=user.team) + else: + qs = qs.filter(extra_access_token=self.request.session["extra_access_token"]) + return qs.distinct() + + def post(self, request, *args, **kwargs): + user = request.user + pool = self.get_object() + + if "solutions_zip" in request.POST: + if user.is_authenticated and user.participates and pool.round == 2\ + and pool.tournament.date_solutions_2 > timezone.now(): + raise PermissionDenied + + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for solution in pool.solutions.all(): + zf.write(solution.file.path, str(solution) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}' \ + .format(_("Solutions of a pool for the round {round} of the tournament {tournament}.zip") + .format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20")) + return resp + elif "syntheses_zip" in request.POST and (not user.is_authenticated or user.organizes): + out = BytesIO() + zf = zipfile.ZipFile(out, "w") + + for synthesis in pool.syntheses.all(): + zf.write(synthesis.file.path, str(synthesis) + ".pdf") + + zf.close() + + resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed") + resp['Content-Disposition'] = 'attachment; filename={}' \ + .format(_("Syntheses of a pool for the round {round} of the tournament {tournament}.zip") + .format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20")) + return resp + + return self.get(request, *args, **kwargs) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..6ed74bf --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +python manage.py compilemessages +python manage.py makemigrations +python manage.py migrate + +nginx + +if [ "$TFJM_STAGE" = "prod" ]; then + gunicorn -b 0.0.0.0:8000 --workers=2 --threads=4 --worker-class=gthread tfjm.wsgi --access-logfile '-' --error-logfile '-'; +else + ./manage.py runserver 0.0.0.0:8000; +fi diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..07a9aaa --- /dev/null +++ b/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,1242 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR yohann.danello@animath.fr, 2020. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TFJM2\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-25 18:23+0200\n" +"PO-Revision-Date: 2020-04-29 02:30+0000\n" +"Last-Translator: Yohann D'ANELLO \n" +"Language-Team: fr \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: apps/api/apps.py:10 +msgid "API" +msgstr "API" + +#: apps/member/apps.py:10 +msgid "member" +msgstr "membre" + +#: apps/member/forms.py:18 +msgid "Choose a role..." +msgstr "Choisir un rôle ..." + +#: apps/member/forms.py:19 apps/member/models.py:138 +msgid "Participant" +msgstr "Participant" + +#: apps/member/forms.py:20 apps/member/models.py:137 +msgid "Coach" +msgstr "Encadrant" + +#: apps/member/models.py:21 templates/member/tfjmuser_detail.html:35 +msgid "email" +msgstr "Adresse électronique" + +#: apps/member/models.py:22 +msgid "This should be valid and will be controlled." +msgstr "Elle doit être valide et sera contrôlée." + +#: apps/member/models.py:30 apps/member/models.py:244 apps/member/models.py:263 +#: apps/member/models.py:306 apps/tournament/models.py:286 +#: apps/tournament/models.py:400 templates/member/tfjmuser_detail.html:16 +msgid "team" +msgstr "équipe" + +#: apps/member/models.py:31 +msgid "Concerns only coaches and participants." +msgstr "Concerne uniquement les encadrants et participants." + +#: apps/member/models.py:37 templates/member/tfjmuser_detail.html:21 +msgid "birth date" +msgstr "date de naissance" + +#: apps/member/models.py:45 +msgid "Male" +msgstr "Homme" + +#: apps/member/models.py:46 +msgid "Female" +msgstr "Femme" + +#: apps/member/models.py:47 +msgid "Non binary" +msgstr "Non binaire" + +#: apps/member/models.py:49 templates/member/tfjmuser_detail.html:26 +msgid "gender" +msgstr "genre" + +#: apps/member/models.py:56 templates/member/tfjmuser_detail.html:31 +msgid "address" +msgstr "adresse" + +#: apps/member/models.py:62 +msgid "postal code" +msgstr "code postal" + +#: apps/member/models.py:69 +msgid "city" +msgstr "ville" + +#: apps/member/models.py:76 +msgid "country" +msgstr "pays" + +#: apps/member/models.py:84 templates/member/tfjmuser_detail.html:39 +msgid "phone number" +msgstr "numéro de téléphone" + +#: apps/member/models.py:91 templates/member/tfjmuser_detail.html:44 +msgid "school" +msgstr "école" + +#: apps/member/models.py:97 +msgid "Seconde or less" +msgstr "Seconde ou inférieur" + +#: apps/member/models.py:98 +msgid "Première" +msgstr "Première" + +#: apps/member/models.py:99 +msgid "Terminale" +msgstr "Terminale" + +#: apps/member/models.py:110 templates/member/tfjmuser_detail.html:51 +msgid "responsible name" +msgstr "nom du responsable" + +#: apps/member/models.py:117 templates/member/tfjmuser_detail.html:56 +msgid "responsible phone" +msgstr "téléphone du responsable" + +#: apps/member/models.py:123 templates/member/tfjmuser_detail.html:61 +msgid "responsible email" +msgstr "email du responsable" + +#: apps/member/models.py:129 apps/tournament/models.py:45 +#: templates/member/tfjmuser_detail.html:67 +#: templates/tournament/tournament_detail.html:42 +msgid "description" +msgstr "description" + +#: apps/member/models.py:135 +msgid "Admin" +msgstr "Administrateur" + +#: apps/member/models.py:136 +msgid "Organizer" +msgstr "Organisateur" + +#: apps/member/models.py:144 apps/tournament/models.py:90 +#: apps/tournament/models.py:215 +msgid "year" +msgstr "année" + +#: apps/member/models.py:171 apps/member/models.py:214 +#: apps/tournament/models.py:393 +msgid "user" +msgstr "utilisateur" + +#: apps/member/models.py:172 +msgid "users" +msgstr "utilisateurs" + +#: apps/member/models.py:189 +msgid "file" +msgstr "fichier" + +#: apps/member/models.py:194 +msgid "uploaded at" +msgstr "téléversé le" + +#: apps/member/models.py:198 +msgid "document" +msgstr "document" + +#: apps/member/models.py:199 +msgid "documents" +msgstr "documents" + +#: apps/member/models.py:220 +msgid "Parental consent" +msgstr "Autorisation parentale" + +#: apps/member/models.py:221 +msgid "Photo consent" +msgstr "Autorisation de droit à l'image" + +#: apps/member/models.py:222 +msgid "Sanitary plug" +msgstr "Fiche sanitaire" + +#: apps/member/models.py:223 apps/tournament/models.py:411 +msgid "Scholarship" +msgstr "Bourse" + +#: apps/member/models.py:225 +msgid "type" +msgstr "type" + +#: apps/member/models.py:229 +msgid "authorization" +msgstr "autorisation" + +#: apps/member/models.py:230 +msgid "authorizations" +msgstr "autorisations" + +#: apps/member/models.py:233 +#, python-brace-format +msgid "{authorization} for user {user}" +msgstr "{authorization} pour l'utilisateur {user}" + +#: apps/member/models.py:248 +msgid "motivation letter" +msgstr "lettre de motivation" + +#: apps/member/models.py:249 +msgid "motivation letters" +msgstr "lettres de motivation" + +#: apps/member/models.py:252 +#, python-brace-format +msgid "Motivation letter of team {team} ({trigram})" +msgstr "Lettre de motivation de l'équipe {team} ({trigram})" + +#: apps/member/models.py:267 +msgid "problem" +msgstr "problème" + +#: apps/member/models.py:272 +msgid "final solution" +msgstr "solution pour la finale" + +#: apps/member/models.py:285 +msgid "solution" +msgstr "solution" + +#: apps/member/models.py:286 apps/tournament/models.py:325 +msgid "solutions" +msgstr "solutions" + +#: apps/member/models.py:291 +#, python-brace-format +msgid "Solution of team {trigram} for problem {problem} for final" +msgstr "" +"Solution de l'équipe {trigram} pour le problème {problem} pour la finale" + +#: apps/member/models.py:294 +#, python-brace-format +msgid "Solution of team {trigram} for problem {problem}" +msgstr "Solution de l'équipe {trigram} pour le problème {problem}" + +#: apps/member/models.py:312 +msgid "Opponent" +msgstr "Opposant" + +#: apps/member/models.py:313 +msgid "Rapporteur" +msgstr "Rapporteur" + +#: apps/member/models.py:315 +msgid "source" +msgstr "source" + +#: apps/member/models.py:320 apps/tournament/models.py:330 +msgid "Round 1" +msgstr "Tour 1" + +#: apps/member/models.py:321 apps/tournament/models.py:331 +msgid "Round 2" +msgstr "Tour 2" + +#: apps/member/models.py:323 apps/tournament/models.py:333 +#: templates/tournament/pool_detail.html:18 +msgid "round" +msgstr "tour" + +#: apps/member/models.py:328 +msgid "final synthesis" +msgstr "synthèse pour la finale" + +#: apps/member/models.py:341 +msgid "synthesis" +msgstr "synthèse" + +#: apps/member/models.py:342 +msgid "syntheses" +msgstr "synthèses" + +#: apps/member/models.py:346 +#, python-brace-format +msgid "" +"Synthesis of team {trigram} that is {source} for the round {round} of " +"tournament {tournament}" +msgstr "" +"Synthèse de l'équipe {trigram} qui est {source} pour le tour {round} du " +"tournoi {tournament}" + +#: apps/member/models.py:358 +msgid "key" +msgstr "clé" + +#: apps/member/models.py:363 +msgid "value" +msgstr "valeur" + +#: apps/member/models.py:367 +msgid "configuration" +msgstr "configuration" + +#: apps/member/models.py:368 +msgid "configurations" +msgstr "configurations" + +#: apps/member/views.py:105 apps/member/views.py:145 +msgid "You can't organize and participate at the same time." +msgstr "Vous ne pouvez pas organiser et participer en même temps." + +#: apps/member/views.py:109 apps/member/views.py:149 +msgid "You are already in a team." +msgstr "Vous êtes déjà dans une équipe." + +#: apps/member/views.py:153 +msgid "This team is full of coachs." +msgstr "Cette équipe est pleine en encadrants." + +#: apps/member/views.py:157 +msgid "This team is full of participants." +msgstr "Cette équipe est pleine en participants." + +#: apps/member/views.py:161 +msgid "This team is already validated or waiting for validation." +msgstr "L'équipe est déjà en attente de validation." + +#: apps/member/views.py:194 +#, python-format +msgid "No %(verbose_name)s found matching the query" +msgstr "" + +#: apps/member/views.py:244 templates/base.html:81 +msgid "All profiles" +msgstr "Tous les profils" + +#: apps/member/views.py:256 templates/base.html:80 +msgid "Orphaned profiles" +msgstr "Profils orphelins" + +#: apps/member/views.py:268 apps/tournament/forms.py:23 templates/base.html:83 +msgid "Organizers" +msgstr "Organisateurs" + +#: apps/tournament/apps.py:10 apps/tournament/models.py:135 +#: apps/tournament/models.py:183 apps/tournament/tables.py:110 +#: templates/tournament/pool_detail.html:21 +#: templates/tournament/team_detail.html:21 +msgid "tournament" +msgstr "tournoi" + +#: apps/tournament/forms.py:31 +msgid "This tournament already exists." +msgstr "Ce tournoi existe déjà." + +#: apps/tournament/forms.py:33 +msgid "The final tournament was already defined." +msgstr "Le tournoi de la finale est déjà défini." + +#: apps/tournament/forms.py:65 +msgid "This organizer already exist." +msgstr "Cet organisateur existe déjà." + +#: apps/tournament/forms.py:94 +msgid "The trigram must be composed of three upcase letters." +msgstr "Le trigramme doit être composé de trois lettres en majuscules." + +#: apps/tournament/forms.py:98 +msgid "This trigram is already used." +msgstr "Ce trigramme est déjà utilisé." + +#: apps/tournament/forms.py:101 +msgid "This name is already used." +msgstr "Ce nom est déjà utilisé." + +#: apps/tournament/forms.py:104 +msgid "This tournament is already closed." +msgstr "Ce tournoi est déjà fermé." + +#: apps/tournament/forms.py:115 +msgid "Access code" +msgstr "Code d'accès" + +#: apps/tournament/forms.py:123 +msgid "The access code must be composed of 6 alphanumeric characters." +msgstr "Le code d'accès doit être composé de 6 caractères alphanumériques." + +#: apps/tournament/forms.py:127 +msgid "This access code is invalid." +msgstr "Ce code d'accès est invalide." + +#: apps/tournament/forms.py:130 +msgid "The team is already validated." +msgstr "L'équipe est déjà validée." + +#: apps/tournament/forms.py:142 +msgid "Problem" +msgstr "Problème" + +#: apps/tournament/forms.py:143 +#, python-format +msgid "Problem #%(problem)d" +msgstr "Problème n°%(problem)d" + +#: apps/tournament/forms.py:152 apps/tournament/forms.py:176 +#, python-format +msgid "" +"Please keep filesize under %(max_size)s. Current filesize %(current_size)s" +msgstr "" +"Merci de ne pas dépasser les %(max_size)s. Le fichier envoyé pèse " +"%(current_size)s." + +#: apps/tournament/forms.py:157 apps/tournament/forms.py:181 +msgid "The file should be a PDF file." +msgstr "Ce fichier doit être au format PDF." + +#: apps/tournament/forms.py:197 apps/tournament/forms.py:210 +#: apps/tournament/forms.py:223 +msgid "Choose a team..." +msgstr "Choisir une équipe ..." + +#: apps/tournament/forms.py:198 +msgid "Team 1" +msgstr "Équipe 1" + +#: apps/tournament/forms.py:205 +msgid "Problem defended by team 1" +msgstr "Problème défendu par l'équipe 1" + +#: apps/tournament/forms.py:211 +msgid "Team 2" +msgstr "Équipe 2" + +#: apps/tournament/forms.py:218 +msgid "Problem defended by team 2" +msgstr "Problème défendu par l'équipe 2" + +#: apps/tournament/forms.py:224 +msgid "Team 3" +msgstr "Équipe 3" + +#: apps/tournament/forms.py:231 +msgid "Problem defended by team 3" +msgstr "Problème défendu par l'équipe 3" + +#: apps/tournament/models.py:19 apps/tournament/models.py:170 +#: templates/tournament/team_detail.html:12 +msgid "name" +msgstr "nom" + +#: apps/tournament/models.py:25 templates/tournament/tournament_detail.html:12 +msgid "organizers" +msgstr "organisateurs" + +#: apps/tournament/models.py:26 +msgid "" +"List of all organizers that can see and manipulate data of the tournament " +"and the teams." +msgstr "" +"Liste des organisateurs qui peuvent manipuler les données du tournoi et des " +"équipes." + +#: apps/tournament/models.py:30 templates/tournament/tournament_detail.html:15 +msgid "size" +msgstr "taille" + +#: apps/tournament/models.py:31 +msgid "Number of teams that are allowed to join the tournament." +msgstr "Nombre d'équipes qui sont autorisées à rejoindre le tournoi." + +#: apps/tournament/models.py:36 templates/tournament/tournament_detail.html:18 +msgid "place" +msgstr "lieu" + +#: apps/tournament/models.py:40 templates/tournament/tournament_detail.html:21 +msgid "price" +msgstr "prix" + +#: apps/tournament/models.py:41 +msgid "Price asked to participants. Free with a scholarship." +msgstr "Prix demandé par participant. Gratuit pour les boursiers." + +#: apps/tournament/models.py:50 +msgid "date start" +msgstr "date de début" + +#: apps/tournament/models.py:55 +msgid "date end" +msgstr "date de fin" + +#: apps/tournament/models.py:60 templates/tournament/tournament_detail.html:27 +msgid "date of registration closing" +msgstr "date de clôture des inscriptions" + +#: apps/tournament/models.py:65 templates/tournament/tournament_detail.html:30 +msgid "date of maximal solution submission" +msgstr "date d'envoi maximal des solutions" + +#: apps/tournament/models.py:70 templates/tournament/tournament_detail.html:33 +msgid "date of maximal syntheses submission for the first round" +msgstr "date d'envoi maximal des notes de synthèses du premier tour" + +#: apps/tournament/models.py:75 templates/tournament/tournament_detail.html:36 +msgid "date when solutions of round 2 are available" +msgstr "date à partir de laquelle les solutions du tour 2 sont disponibles" + +#: apps/tournament/models.py:80 templates/tournament/tournament_detail.html:39 +msgid "date of maximal syntheses submission for the second round" +msgstr "date d'envoi maximal des notes de synthèses pour le second tour" + +#: apps/tournament/models.py:84 +msgid "final tournament" +msgstr "finale" + +#: apps/tournament/models.py:85 +msgid "It should be only one final tournament." +msgstr "Il ne doit y avoir qu'une seule finale." + +#: apps/tournament/models.py:136 +msgid "tournaments" +msgstr "tournois" + +#: apps/tournament/models.py:175 templates/tournament/team_detail.html:15 +msgid "trigram" +msgstr "trigramme" + +#: apps/tournament/models.py:176 +msgid "" +"The trigram should be composed of 3 capitalize letters, that is a funny " +"acronym for the team." +msgstr "" +"Le trigramme doit être composé de trois lettres en majuscule, qui doit être " +"un acronyme amusant représentant l'équipe." + +#: apps/tournament/models.py:184 +msgid "The tournament where the team is registered." +msgstr "Le tournoi où l'équipe est inscrite." + +#: apps/tournament/models.py:189 +msgid "inscription date" +msgstr "date d'inscription" + +#: apps/tournament/models.py:195 apps/tournament/models.py:420 +msgid "Registration not validated" +msgstr "Inscription non validée" + +#: apps/tournament/models.py:196 apps/tournament/models.py:421 +msgid "Waiting for validation" +msgstr "En attente de validation" + +#: apps/tournament/models.py:197 apps/tournament/models.py:422 +msgid "Registration validated" +msgstr "Inscription validée" + +#: apps/tournament/models.py:199 apps/tournament/models.py:424 +#: templates/tournament/team_detail.html:32 +msgid "validation status" +msgstr "statut de validation" + +#: apps/tournament/models.py:204 +msgid "selected for final" +msgstr "sélectionnée pour la finale" + +#: apps/tournament/models.py:210 templates/tournament/team_detail.html:18 +msgid "access code" +msgstr "code d'accès" + +#: apps/tournament/models.py:287 apps/tournament/models.py:319 +#: templates/tournament/pool_detail.html:15 +msgid "teams" +msgstr "équipes" + +#: apps/tournament/models.py:339 templates/tournament/pool_detail.html:12 +msgid "juries" +msgstr "jurys" + +#: apps/tournament/models.py:345 +msgid "extra access token" +msgstr "code d'accès spécial" + +#: apps/tournament/models.py:346 +msgid "Let other users access to the pool data without logging in." +msgstr "Permet à d'autres utilisateurs d'accéder au contenu de la poule sans connexion." + +#: apps/tournament/models.py:380 +msgid "pool" +msgstr "poule" + +#: apps/tournament/models.py:381 +msgid "pools" +msgstr "poules" + +#: apps/tournament/models.py:406 +msgid "Not paid" +msgstr "Non payé" + +#: apps/tournament/models.py:407 +msgid "Credit card" +msgstr "Carte bancaire" + +#: apps/tournament/models.py:408 +msgid "Bank check" +msgstr "Chèque bancaire" + +#: apps/tournament/models.py:409 +msgid "Bank transfer" +msgstr "Virement bancaire" + +#: apps/tournament/models.py:410 +msgid "Cash" +msgstr "Espèces" + +#: apps/tournament/models.py:414 +msgid "payment method" +msgstr "moyen de paiement" + +#: apps/tournament/models.py:428 +msgid "payment" +msgstr "paiement" + +#: apps/tournament/models.py:429 +msgid "payments" +msgstr "paiements" + +#: apps/tournament/models.py:432 +#, python-brace-format +msgid "Payment of {user}" +msgstr "Paiement de {user}" + +#: apps/tournament/tables.py:22 templates/tournament/tournament_detail.html:24 +msgid "dates" +msgstr "dates" + +#: apps/tournament/tables.py:26 +msgid "From {start:%b %d %Y} to {end:%b %d %Y}" +msgstr "Du {start: %d %b %Y} au {end:%d %b %Y}" + +#: apps/tournament/tables.py:71 apps/tournament/tables.py:147 +msgid "Tournament" +msgstr "Tournoi" + +#: apps/tournament/tables.py:85 apps/tournament/tables.py:124 +#: templates/tournament/team_detail.html:135 +#: templates/tournament/team_detail.html:144 +msgid "Download" +msgstr "Télécharger" + +#: apps/tournament/tables.py:140 +msgid "Problems" +msgstr "Problèmes" + +#: apps/tournament/views.py:68 +msgid "Tournaments list" +msgstr "Liste des tournois" + +#: apps/tournament/views.py:91 +msgid "Add tournament" +msgstr "Ajouter un tournoi" + +#: apps/tournament/views.py:108 +#, python-brace-format +msgid "Tournament of {name}" +msgstr "Tournoi de {name}" + +#: apps/tournament/views.py:146 +msgid "Update tournament" +msgstr "Modifier le tournoi" + +#: apps/tournament/views.py:195 apps/tournament/views.py:323 +#, python-brace-format +msgid "Solutions for team {team}.zip" +msgstr "Solutions pour l'équipe {team}.zip" + +#: apps/tournament/views.py:251 +msgid "Information about team" +msgstr "Informations sur l'équipe" + +#: apps/tournament/views.py:266 +msgid "Update team" +msgstr "Modifier l'équipe" + +#: apps/tournament/views.py:284 +msgid "Add organizer" +msgstr "Ajouter un organisateur" + +#: apps/tournament/views.py:307 templates/base.html:108 templates/base.html:118 +#: templates/base.html:132 templates/tournament/pool_detail.html:31 +msgid "Solutions" +msgstr "Solutions" + +#: apps/tournament/views.py:347 +msgid "" +"You can't publish your solution anymore. Deadline: {date:%m-%d-%Y %H:%M}." +msgstr "" +"Vous ne pouvez plus publier vos solutions. Deadline : {date:%d/%m/%Y %H:%M}." + +#: apps/tournament/views.py:376 +msgid "All solutions" +msgstr "Toutes les solutions" + +#: apps/tournament/views.py:395 +#, python-brace-format +msgid "Solutions for tournament {tournament}.zip" +msgstr "Solutions pour le tournoi {tournament}.zip" + +#: apps/tournament/views.py:432 templates/base.html:111 templates/base.html:121 +#: templates/base.html:135 templates/tournament/pool_detail.html:57 +msgid "Syntheses" +msgstr "Synthèses" + +#: apps/tournament/views.py:448 +#, python-brace-format +msgid "Syntheses for team {team}.zip" +msgstr "Notes de synthèse de l'équipe {team}.zip" + +#: apps/tournament/views.py:473 +msgid "" +"You can't publish your synthesis anymore for the first round. Deadline: " +"{date:%m-%d-%Y %H:%M}." +msgstr "" +"Vous ne pouvez plus envoyer vos notes de synthèse pour le premier tour. " +"Deadline : {date:%d/%m/%Y %h:%M}." + +#: apps/tournament/views.py:479 +msgid "" +"You can't publish your synthesis anymore for the second round. Deadline: " +"{date:%m-%d-%Y %H:%M}." +msgstr "" +"Vous ne pouvez plus envoyer vos notes de synthèse pour le second tour. " +"Deadline : {date:%d/%m/%Y %h:%M}." + +#: apps/tournament/views.py:509 +msgid "All syntheses" +msgstr "Toutes les notes de synthèses" + +#: apps/tournament/views.py:528 +#, python-brace-format +msgid "Syntheses for tournament {tournament}.zip" +msgstr "Notes de synthèse pour le tournoi {tournament}.zip" + +#: apps/tournament/views.py:571 templates/base.html:125 templates/base.html:138 +msgid "Pools" +msgstr "Poules" + +#: apps/tournament/views.py:594 +msgid "Create pool" +msgstr "Créer une poule" + +#: apps/tournament/views.py:611 +msgid "Pool detail" +msgstr "Détails d'une poule" + +#: apps/tournament/views.py:644 +#, python-brace-format +msgid "" +"Solutions of a pool for the round {round} of the tournament {tournament}.zip" +msgstr "Solutions d'une poule du tour {round} du tournoi {tournament}.zip" + +#: apps/tournament/views.py:658 +#, python-brace-format +msgid "" +"Syntheses of a pool for the round {round} of the tournament {tournament}.zip" +msgstr "Synthèse d'une poule du tour {round} du tournoi {tournament}.zip" + +#: templates/400.html:6 +msgid "Bad request" +msgstr "Requête invalide" + +#: templates/400.html:7 +msgid "" +"Sorry, your request was bad. Don't know what could be wrong. An email has " +"been sent to webmasters with the details of the error. You can now drink a " +"coke." +msgstr "" +"Désolé, votre requête comporte une erreur. Aucune idée de ce qui a pu se " +"passer. Un email a été envoyé au développeur avec les détails de l'erreur. " +"Vous pouvez désormais aller chercher un coca." + +#: templates/403.html:6 +msgid "Permission denied" +msgstr "Accès refusé" + +#: templates/403.html:7 +msgid "You don't have the right to perform this request." +msgstr "Vous n'avez pas la permission d'effectuer cette requête." + +#: templates/403.html:10 templates/404.html:10 +msgid "Exception message:" +msgstr "Message d'erreur :" + +#: templates/404.html:6 +msgid "Page not found" +msgstr "Page non trouvée" + +#: templates/404.html:7 +#, python-format +msgid "" +"The requested path %(request_path)s was not found on the server." +msgstr "" +"Le chemin demandé %(request_path)s n'a pas été trouvé sur le " +"serveur." + +#: templates/500.html:6 +msgid "Server error" +msgstr "Erreur du serveur" + +#: templates/500.html:7 +msgid "" +"Sorry, an error occurred when processing your request. An email has been " +"sent to webmasters with the detail of the error, and this will be fixed " +"soon. You can now drink a beer." +msgstr "" +"Désolé, votre requête comporte une erreur. Aucune idée de ce qui a pu se " +"passer. Un email a été envoyé au développeur avec les détails de l'erreur. " +"Vous pouvez désormais aller chercher une bière." + +#: templates/base.html:11 +msgid "The inscription site of the TFJM²." +msgstr "Le site d'inscription au TFJM²." + +#: templates/base.html:73 +msgid "Home" +msgstr "Accueil" + +#: templates/base.html:76 +msgid "Tournament list" +msgstr "Liste des tournois" + +#: templates/base.html:89 +msgid "My account" +msgstr "Mon compte" + +#: templates/base.html:94 +msgid "Add a team" +msgstr "Ajouter une équipe" + +#: templates/base.html:97 +msgid "Join a team" +msgstr "Rejoindre une équipe" + +#: templates/base.html:101 +msgid "My team" +msgstr "Mon équipe" + +#: templates/base.html:144 +msgid "Make a gift" +msgstr "Faire un don" + +#: templates/base.html:148 +msgid "Administration" +msgstr "Administration" + +#: templates/base.html:155 +msgid "Return to admin view" +msgstr "Retour à l'interface administrateur" + +#: templates/base.html:160 templates/registration/login.html:7 +#: templates/registration/login.html:8 templates/registration/login.html:22 +#: templates/registration/password_reset_complete.html:10 +msgid "Log in" +msgstr "Connexion" + +#: templates/base.html:163 templates/registration/signup.html:5 +#: templates/registration/signup.html:8 templates/registration/signup.html:14 +msgid "Sign up" +msgstr "S'inscrire" + +#: templates/base.html:167 +msgid "Log out" +msgstr "Déconnexion" + +#: templates/django_filters/rest_framework/crispy_form.html:4 +#: templates/django_filters/rest_framework/form.html:2 +msgid "Field filters" +msgstr "Filtres" + +#: templates/django_filters/rest_framework/form.html:5 +#: templates/member/my_account.html:9 templates/tournament/add_organizer.html:9 +#: templates/tournament/pool_form.html:9 +#: templates/tournament/solutions_list.html:24 +#: templates/tournament/syntheses_list.html:40 +#: templates/tournament/team_form.html:9 +#: templates/tournament/tournament_form.html:9 +msgid "Submit" +msgstr "Envoyer" + +#: templates/member/my_account.html:14 +msgid "Update my password" +msgstr "Changer mon mot de passe" + +#: templates/member/profile_list.html:9 +msgid "Add an organizer" +msgstr "Ajouter un organisateur" + +#: templates/member/tfjmuser_detail.html:12 +msgid "role" +msgstr "rôle" + +#: templates/member/tfjmuser_detail.html:47 +msgid "class" +msgstr "classe" + +#: templates/member/tfjmuser_detail.html:76 +#: templates/tournament/team_detail.html:129 +msgid "Documents" +msgstr "Documents" + +#: templates/member/tfjmuser_detail.html:84 +#, python-format +msgid "View site as %(tfjmuser)s" +msgstr "Voir le site en tant que %(tfjmuser)s" + +#: templates/registration/email_validation_complete.html:6 +msgid "Your email have successfully been validated." +msgstr "Votre adresse e-mail a bien été validée." + +#: templates/registration/email_validation_complete.html:8 +#, python-format +msgid "You can now log in." +msgstr "Vous pouvez désormais vous connecter" + +#: templates/registration/email_validation_complete.html:10 +msgid "" +"You must pay now your membership in the Kfet to complete your registration." +msgstr "" + +#: templates/registration/email_validation_complete.html:13 +msgid "" +"The link was invalid. The token may have expired. Please send us an email to " +"activate your account." +msgstr "" +"Le lien est invalide. Le jeton a du expirer. Merci de nous envoyer un mail " +"afin d'activer votre compte." + +#: templates/registration/logged_out.html:8 +msgid "Thanks for spending some quality time with the Web site today." +msgstr "Merci d'avoir utilisé la plateforme du TFJM²." + +#: templates/registration/logged_out.html:9 +msgid "Log in again" +msgstr "Se connecter à nouveau" + +#: templates/registration/login.html:13 +#, python-format +msgid "" +"You are authenticated as %(user)s, but are not authorized to access this " +"page. Would you like to login to a different account?" +msgstr "" +"Vous êtes déjà connecté sous le nom %(user)s, mais vous n'êtes pas autorisés " +"à accéder à cette page. Souhaitez-vous vous connecter sous un compte " +"différent ?" + +#: templates/registration/login.html:23 +msgid "Forgotten your password or username?" +msgstr "Mot de passe oublié ?" + +#: templates/registration/mails/email_validation_email.html:3 +msgid "Hi" +msgstr "Bonjour" + +#: templates/registration/mails/email_validation_email.html:5 +msgid "" +"You recently registered on the Note Kfet. Please click on the link below to " +"confirm your registration." +msgstr "" + +#: templates/registration/mails/email_validation_email.html:9 +msgid "" +"This link is only valid for a couple of days, after that you will need to " +"contact us to validate your email." +msgstr "" + +#: templates/registration/mails/email_validation_email.html:11 +msgid "" +"After that, you'll have to wait that someone validates your account before " +"you can log in. You will need to pay your membership in the Kfet." +msgstr "" + +#: templates/registration/mails/email_validation_email.html:13 +msgid "Thanks" +msgstr "Merci" + +#: templates/registration/mails/email_validation_email.html:15 +msgid "The Note Kfet team." +msgstr "" + +#: templates/registration/password_change_done.html:8 +msgid "Your password was changed." +msgstr "Votre mot de passe a été changé" + +#: templates/registration/password_change_form.html:9 +msgid "" +"Please enter your old password, for security's sake, and then enter your new " +"password twice so we can verify you typed it in correctly." +msgstr "" +"Veuillez entrer votre ancien mot de passe, pour des raisons de sécurité, " +"puis entrer votre mot de passe deux fois afin de vérifier que vous l'avez " +"tapé correctement." + +#: templates/registration/password_change_form.html:11 +#: templates/registration/password_reset_confirm.html:12 +msgid "Change my password" +msgstr "Changer mon mot de passe" + +#: templates/registration/password_reset_complete.html:8 +msgid "Your password has been set. You may go ahead and log in now." +msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter." + +#: templates/registration/password_reset_confirm.html:9 +msgid "" +"Please enter your new password twice so we can verify you typed it in " +"correctly." +msgstr "" +"Veuillez taper votre nouveau mot de passe deux fois afin de s'assurer que " +"vous l'ayez tapé correctement." + +#: templates/registration/password_reset_confirm.html:15 +msgid "" +"The password reset link was invalid, possibly because it has already been " +"used. Please request a new password reset." +msgstr "" +"Le lien de réinitialisation du mot de passe est invalide, sans doute parce " +"qu'il a été déjà utilisé. Veuillez demander une nouvelle demande de " +"réinitialisation." + +#: templates/registration/password_reset_done.html:8 +msgid "" +"We've emailed you instructions for setting your password, if an account " +"exists with the email you entered. You should receive them shortly." +msgstr "" +"Nous vous avons envoyé des instructions pour réinitialiser votre mot de " +"passe, si un compte existe avec l'adresse email entrée. Vous devriez les " +"recevoir d'ici peu." + +#: templates/registration/password_reset_done.html:9 +msgid "" +"If you don't receive an email, please make sure you've entered the address " +"you registered with, and check your spam folder." +msgstr "" +"Si vous n'avez pas reçu d'email, merci de vérifier que vous avez entré " +"l'adresse avec laquelle vous êtes inscrits, et vérifier vos spams." + +#: templates/registration/password_reset_form.html:8 +msgid "" +"Forgotten your password? Enter your email address below, and we'll email " +"instructions for setting a new one." +msgstr "" +"Mot de passe oublié ? Entrez votre adresse email ci-dessous, et nous vous " +"enverrons des instructions pour en définir un nouveau." + +#: templates/registration/password_reset_form.html:11 +msgid "Reset my password" +msgstr "Réinitialiser mon mot de passe" + +#: templates/tournament/pool_detail.html:36 +msgid "Solutions will be available here for teams from:" +msgstr "Les solutions seront disponibles ici pour les équipes à partir du :" + +#: templates/tournament/pool_detail.html:49 +#: templates/tournament/pool_detail.html:73 +msgid "Download ZIP archive" +msgstr "Télécharger l'archive ZIP" + +#: templates/tournament/pool_detail.html:61 +#: templates/tournament/syntheses_list.html:7 +msgid "Templates for syntheses are available here:" +msgstr "Le modèle de note de synthèse est disponible ici :" + +#: templates/tournament/pool_detail.html:83 +msgid "Pool list" +msgstr "Liste des poules" + +#: templates/tournament/pool_detail.html:89 +msgid "" +"Give this link to juries to access this page (warning: should stay " +"confidential and only given to juries of this pool):" +msgstr "" +"Donnez ce lien aux jurys pour leur permettre d'accéder à cette page " +"(attention : ce lien doit rester confidentiel et ne doit être donné " +"exclusivement qu'à des jurys) :" + +#: templates/tournament/pool_list.html:10 +msgid "Add pool" +msgstr "Ajouter une poule" + +#: templates/tournament/solutions_list.html:9 +#, python-format +msgid "You can upload your solutions until %(deadline)s." +msgstr "Vous pouvez envoyer vos solutions jusqu'au %(deadline)s." + +#: templates/tournament/solutions_list.html:14 +msgid "" +"The deadline to send your solutions is reached. However, you have an extra " +"time of 30 minutes to send your papers, no panic :)" +msgstr "" +"La date limite pour envoyer vos solutions est dépassée. Toutefois, vous avez " +"droit à un délai supplémentaire de 30 minutes pour envoyer vos papiers, pas " +"de panique :)" + +#: templates/tournament/solutions_list.html:16 +msgid "You can't upload your solutions anymore." +msgstr "Vous ne pouvez plus publier vos solutions." + +#: templates/tournament/solutions_orga_list.html:14 +#: templates/tournament/syntheses_orga_list.html:14 +#, python-format +msgid "%(tournament)s — ZIP" +msgstr "%(tournament)s — ZIP" + +#: templates/tournament/syntheses_list.html:14 +#: templates/tournament/syntheses_list.html:26 +#, python-format +msgid "You can upload your syntheses for round %(round)s until %(deadline)s." +msgstr "" +"Vous pouvez envoyer vos notes de synthèses pour le tour %(round)s jusqu'au " +"%(deadline)s." + +#: templates/tournament/syntheses_list.html:18 +#: templates/tournament/syntheses_list.html:30 +#, python-format +msgid "" +"The deadline to send your syntheses for the round %(round)s is reached. " +"However, you have an extra time of 30 minutes to send your papers, no " +"panic :)" +msgstr "" +"La date limite pour envoyer vos notes de synthèses pour le tour %(round)s " +"est dépassée. Toutefois, vous avez droit à un délai supplémentaire de 30 " +"minutes pour envoyer vos papiers, pas de panique :)" + +#: templates/tournament/syntheses_list.html:22 +#: templates/tournament/syntheses_list.html:34 +#, python-format +msgid "You can't upload your syntheses for the round %(round)s anymore." +msgstr "" +"Vous ne pouvez plus publier vos notes de synthèses pour le tour %(round)s." + +#: templates/tournament/team_detail.html:8 +msgid "Team" +msgstr "Équipe" + +#: templates/tournament/team_detail.html:25 +msgid "coachs" +msgstr "encadrants" + +#: templates/tournament/team_detail.html:28 +msgid "participants" +msgstr "participants" + +#: templates/tournament/team_detail.html:39 +msgid "Send a mail to people in this team" +msgstr "Envoyer un mail à toutes les personnes de cette équipe" + +#: templates/tournament/team_detail.html:49 +msgid "Edit team" +msgstr "Modifier l'équipe" + +#: templates/tournament/team_detail.html:53 +msgid "Select for final" +msgstr "Sélectionner pour la finale" + +#: templates/tournament/team_detail.html:59 +msgid "Delete team" +msgstr "Supprimer l'équipe" + +#: templates/tournament/team_detail.html:61 +msgid "Leave this team" +msgstr "Quitter l'équipe" + +#: templates/tournament/team_detail.html:105 +msgid "The team is waiting about validation." +msgstr "L'équipe est en attente de validation" + +#: templates/tournament/team_detail.html:112 +msgid "Message addressed to the team:" +msgstr "Message adressé à l'équipe :" + +#: templates/tournament/team_detail.html:114 +msgid "Message..." +msgstr "Message ..." + +#: templates/tournament/team_detail.html:119 +msgid "Invalidate team" +msgstr "Invalider l'équipe" + +#: templates/tournament/team_detail.html:120 +msgid "Validate team" +msgstr "Valider l'équipe" + +#: templates/tournament/team_detail.html:133 +msgid "Motivation letter:" +msgstr "Lettre de motivation :" + +#: templates/tournament/team_detail.html:152 +msgid "Download solutions as ZIP" +msgstr "Télécharger les solutions en archive ZIP" + +#: templates/tournament/tournament_detail.html:22 +msgid "Free" +msgstr "Gratuit" + +#: templates/tournament/tournament_detail.html:25 +msgid "From" +msgstr "Du" + +#: templates/tournament/tournament_detail.html:25 +msgid "to" +msgstr "à" + +#: templates/tournament/tournament_detail.html:48 +msgid "Send a mail to all people in this tournament" +msgstr "Envoyer un mail à toutes les personnes du tournoi" + +#: templates/tournament/tournament_detail.html:49 +msgid "Send a mail to all people in this tournament that are in a valid team" +msgstr "" +"Envoyer un mail à toutes les personnes du tournoi dans une équipe valide" + +#: templates/tournament/tournament_detail.html:56 +msgid "Edit tournament" +msgstr "Modifier le tournoi" + +#: templates/tournament/tournament_detail.html:63 +msgid "Teams" +msgstr "Équipes" + +#: templates/tournament/tournament_list.html:8 +msgid "Send a mail to all people that are in a team" +msgstr "Envoyer un mail à toutes les personnes dans une équipe" + +#: templates/tournament/tournament_list.html:9 +msgid "Send a mail to all people that are in a valid team" +msgstr "Envoyer un mail à toutes les personnes dans une équipe validée" + +#: templates/tournament/tournament_list.html:15 +msgid "Add a tournament" +msgstr "Ajouter un tournoi" + +#: tfjm/settings.py:147 +msgid "English" +msgstr "Anglais" + +#: tfjm/settings.py:148 +msgid "French" +msgstr "Français" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..2bf0cbf --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/nginx_tfjm.conf b/nginx_tfjm.conf new file mode 100644 index 0000000..be143ce --- /dev/null +++ b/nginx_tfjm.conf @@ -0,0 +1,19 @@ +upstream tfjm { + server 127.0.0.1:8000; +} + +server { + listen 80; + server_name tfjm; + + location / { + proxy_pass http://tfjm; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location /static { + alias /code/static/; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4338302 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +bcrypt +Django~=3.0 +django-allauth +django-crispy-forms +django-extensions +django-filter +django-polymorphic +django-tables2 +djangorestframework +django-rest-polymorphic +mysqlclient +psycopg2-binary +ptpython +gunicorn \ No newline at end of file diff --git a/static/Autorisation_droit_image_majeur.tex b/static/Autorisation_droit_image_majeur.tex new file mode 100644 index 0000000..7cb1727 --- /dev/null +++ b/static/Autorisation_droit_image_majeur.tex @@ -0,0 +1,113 @@ +\documentclass[a4paper,french,11pt]{article} + +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{lmodern} +\usepackage[frenchb]{babel} + +\usepackage{fancyhdr} +\usepackage{graphicx} +\usepackage{amsmath} +\usepackage{amssymb} +%\usepackage{anyfontsize} +\usepackage{fancybox} +\usepackage{eso-pic,graphicx} +\usepackage{xcolor} + + +% Specials +\newcommand{\writingsep}{\vrule height 4ex width 0pt} + +% Page formating +\hoffset -1in +\voffset -1in +\textwidth 180 mm +\textheight 250 mm +\oddsidemargin 15mm +\evensidemargin 15mm +\pagestyle{fancy} + +% Headers and footers +\fancyfoot{} +\lhead{} +\rhead{} +\renewcommand{\headrulewidth}{0pt} +\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018} +\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.} + +\begin{document} + +\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}} + +\vfill + +\begin{center} + + +\LARGE +Autorisation d'enregistrement et de diffusion de l'image ({TOURNAMENT_NAME}) +\end{center} +\normalsize + + +\thispagestyle{empty} + +\bigskip + + + +Je soussign\'e {PARTICIPANT_NAME}\\ +demeurant au {ADDRESS} + +\medskip +Cochez la/les cases correspondantes.\\ +\medskip + + \fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ du {START_DATE} au {END_DATE} {YEAR} à : {PLACE}, \`a me photographier ou \`a me filmer et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion sur son site et sur les sites partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit d’utiliser mon image sur tous ses supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente, cessionnaire des droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\ + +\medskip +Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la publication et la diffusion de l'image ainsi que des commentaires l'accompagnant ne portent pas atteinte \`a la vie priv\'ee, \`a la dignit\'e et \`a la r\'eputation de la personne photographiée.\\ + +\medskip + \fbox{\textcolor{white}{A}} Autorise la diffusion dans les medias (Presse, T\'el\'evision, Internet) de photographies prises \`a l'occasion d’une \'eventuelle m\'ediatisation de cet événement.\\ + + \medskip + +Conform\'ement \`a la loi informatique et libert\'es du 6 janvier 1978, vous disposez d'un droit de libre acc\`es, de rectification, de modification et de suppression des donn\'ees qui vous concernent. +Cette autorisation est donc r\'evocable \`a tout moment sur volont\'e express\'ement manifest\'ee par lettre recommand\'ee avec accus\'e de r\'eception adress\'ee \`a Animath, IHP, 11 rue Pierre et Marie Curie, 75231 Paris cedex 05.\\ + +\medskip + \fbox{\textcolor{white}{A}} Autorise Animath à conserver mes données personnelles, dans le cadre défini par la loi n 78-17 du 6 janvier 1978 relative à l'informatique, aux fichiers et aux libertés et les textes la modifiant, pendant une durée de quatre ans à compter de ma dernière participation à un événement organisé par Animath.\\ + + \medskip + \fbox{\textcolor{white}{A}} J'accepte d'être tenu informé d'autres activités organisées par l'association et ses partenaires. + +\bigskip + +Signature pr\'ec\'ed\'ee de la mention \og lu et approuv\'e \fg{} + +\medskip + + + +\begin{minipage}[c]{0.5\textwidth} + +\underline{L'\'el\`eve :}\\ + +Fait \`a :\\ +le +\end{minipage} + + +\vfill +\vfill +\begin{minipage}[c]{0.5\textwidth} +\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018 +\end{minipage} +\begin{minipage}[c]{0.5\textwidth} +\footnotesize +\begin{flushright} +Association agréée par\\le Ministère de l'éducation nationale. +\end{flushright} +\end{minipage} +\end{document} diff --git a/static/Autorisation_droit_image_mineur.tex b/static/Autorisation_droit_image_mineur.tex new file mode 100644 index 0000000..4f14a43 --- /dev/null +++ b/static/Autorisation_droit_image_mineur.tex @@ -0,0 +1,122 @@ +\documentclass[a4paper,french,11pt]{article} + +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{lmodern} +\usepackage[frenchb]{babel} + +\usepackage{fancyhdr} +\usepackage{graphicx} +\usepackage{amsmath} +\usepackage{amssymb} +%\usepackage{anyfontsize} +\usepackage{fancybox} +\usepackage{eso-pic,graphicx} +\usepackage{xcolor} + + +% Specials +\newcommand{\writingsep}{\vrule height 4ex width 0pt} + +% Page formating +\hoffset -1in +\voffset -1in +\textwidth 180 mm +\textheight 250 mm +\oddsidemargin 15mm +\evensidemargin 15mm +\pagestyle{fancy} + +% Headers and footers +\fancyfoot{} +\lhead{} +\rhead{} +\renewcommand{\headrulewidth}{0pt} +\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018} +\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.} + +\begin{document} + +\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}} + +\vfill + +\begin{center} + + +\LARGE +Autorisation d'enregistrement et de diffusion de l'image +({TOURNAMENT_NAME}) +\end{center} +\normalsize + + +\thispagestyle{empty} + +\bigskip + + + +Je soussign\'e \dotfill (p\`ere, m\`ere, responsable l\'egal) \\ +agissant en qualit\'e de repr\'esentant de {PARTICIPANT_NAME}\\ +demeurant au {ADDRESS} + +\medskip +Cochez la/les cases correspondantes.\\ +\medskip + + \fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ du {START_DATE} au {END_DATE} {YEAR} à : {PLACE}, \`a photographier ou \`a filmer l'enfant et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion sur son site et sur les sites partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit d’utiliser l'image de l'enfant sur tous ses supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente, cessionnaire des droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\ + +\medskip +Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la publication et la diffusion de l'image de l'enfant ainsi que des commentaires l'accompagnant ne portent pas atteinte \`a la vie priv\'ee, \`a la dignit\'e et \`a la r\'eputation de l’enfant.\\ + +\medskip + \fbox{\textcolor{white}{A}} Autorise la diffusion dans les medias (Presse, T\'el\'evision, Internet) de photographies de mon enfant prises \`a l'occasion d’une \'eventuelle m\'ediatisation de cet événement.\\ + + \medskip + +Conform\'ement \`a la loi informatique et libert\'es du 6 janvier 1978, vous disposez d'un droit de libre acc\`es, de rectification, de modification et de suppression des donn\'ees qui vous concernent. +Cette autorisation est donc r\'evocable \`a tout moment sur volont\'e express\'ement manifest\'ee par lettre recommand\'ee avec accus\'e de r\'eception adress\'ee \`a Animath, IHP, 11 rue Pierre et Marie Curie, 75231 Paris cedex 05.\\ + +\medskip + \fbox{\textcolor{white}{A}} Autorise Animath à conserver mes données personnelles, dans le cadre défini par la loi n 78-17 du 6 janvier 1978 relative à l'informatique, aux fichiers et aux libertés et les textes la modifiant, pendant une durée de quatre ans à compter de ma dernière participation à un événement organisé par Animath.\\ + + \medskip + \fbox{\textcolor{white}{A}} J'accepte d'être tenu informé d'autres activités organisées par l'association et ses partenaires. + + \bigskip + +Signatures pr\'ec\'ed\'ees de la mention \og lu et approuv\'e \fg{} + +\medskip + + +\begin{minipage}[c]{0.5\textwidth} + +\underline{Le responsable l\'egal :}\\ + +Fait \`a :\\ +le : + +\end{minipage} +\begin{minipage}[c]{0.5\textwidth} + +\underline{L'\'el\`eve :}\\ + +Fait \`a :\\ +le +\end{minipage} + + +\vfill +\vfill +\begin{minipage}[c]{0.5\textwidth} +\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018 +\end{minipage} +\begin{minipage}[c]{0.5\textwidth} +\footnotesize +\begin{flushright} +Association agréée par\\le Ministère de l'éducation nationale. +\end{flushright} +\end{minipage} +\end{document} diff --git a/static/Autorisation_parentale.tex b/static/Autorisation_parentale.tex new file mode 100644 index 0000000..6c56ac4 --- /dev/null +++ b/static/Autorisation_parentale.tex @@ -0,0 +1,66 @@ +\documentclass[a4paper,french,11pt]{article} + +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{lmodern} +\usepackage[french]{babel} + +\usepackage{fancyhdr} +\usepackage{graphicx} +\usepackage{amsmath} +\usepackage{amssymb} +%\usepackage{anyfontsize} +\usepackage{fancybox} +\usepackage{eso-pic,graphicx} +\usepackage{xcolor} + + +% Specials +\newcommand{\writingsep}{\vrule height 4ex width 0pt} + +% Page formating +\hoffset -1in +\voffset -1in +\textwidth 180 mm +\textheight 250 mm +\oddsidemargin 15mm +\evensidemargin 15mm +\pagestyle{fancy} + +% Headers and footers +\fancyfoot{} +\lhead{} +\rhead{} +\renewcommand{\headrulewidth}{0pt} +\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018} +\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.} + +\begin{document} + +\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}} + +\vfill + +\begin{center} +\Large \bf Autorisation parentale pour les mineurs ({TOURNAMENT_NAME}) +\end{center} + +Je soussigné(e) \hrulefill,\\ +responsable légal, demeurant \writingsep\hrulefill\\ +\writingsep\hrulefill,\\ +\writingsep autorise {PARTICIPANT_NAME},\\ +né(e) le {BIRTHDAY}, +à participer au Tournoi Français des Jeunes Mathématiciennes et Mathématiciens ($\mathbb{TFJM}^2$) organisé \`a : {PLACE}, du {START_DATE} au {END_DATE} {YEAR}. + +{PRONOUN} se rendra au lieu indiqu\'e ci-dessus le vendredi matin et quittera les lieux l'après-midi du dimanche par ses propres moyens et sous la responsabilité du représentant légal. + + + +\vspace{8ex} + +Fait à \vrule width 10cm height 0pt depth 0.4pt, le \phantom{232323}/\phantom{XXX}/{YEAR}, + +\vfill +\vfill + +\end{document} diff --git a/static/Fiche synthèse.pdf b/static/Fiche synthèse.pdf new file mode 100644 index 0000000..af8ed1c Binary files /dev/null and b/static/Fiche synthèse.pdf differ diff --git a/static/Fiche synthèse.tex b/static/Fiche synthèse.tex new file mode 100644 index 0000000..bc2daa9 --- /dev/null +++ b/static/Fiche synthèse.tex @@ -0,0 +1,194 @@ +\documentclass{article} + +\usepackage[utf8]{inputenc} +\usepackage[french]{babel} +\usepackage{graphicx} + +\usepackage[left=2cm,right=2cm,top=2cm,bottom=2cm]{geometry} % marges + +\usepackage{amsthm} +\usepackage{amsmath} +\usepackage{amsfonts} +\usepackage{amssymb} +\usepackage{tikz} + +\newcommand{\N}{{\bf N}} +\newcommand{\Z}{{\bf Z}} +\newcommand{\Q}{{\bf Q}} +\newcommand{\R}{{\bf R}} +\newcommand{\C}{{\bf C}} +\newcommand{\A}{{\bf A}} + +\newtheorem{theo}{Théorème} +\newtheorem{theo-defi}[theo]{Théorème-Définition} +\newtheorem{defi}[theo]{Définition} +\newtheorem{lemme}[theo]{Lemme} +\newtheorem{slemme}[theo]{Sous-lemme} +\newtheorem{prop}[theo]{Proposition} +\newtheorem{coro}[theo]{Corollaire} +\newtheorem{conj}[theo]{Conjecture} + +\title{Note de synthèse} + +\begin{document} +\pagestyle{empty} + +\begin{center} +\begin{Huge} +$\mathbb{TFJM}^2$ +\end{Huge} + +\bigskip + +\begin{Large} +NOTE DE SYNTHESE +\end{Large} +\end{center} + +Tour \underline{~~~~} poule \underline{~~~~} + +\medskip + +Problème \underline{~~~~} défendu par l'équipe \underline{~~~~~~~~~~~~~~~~~~~~~~~~} + +\medskip + +Synthèse par l'équipe \underline{~~~~~~~~~~~~~~~~~~~~~~~~} dans le rôle de : ~ $\square$ Opposant ~ $\square$ Rapporteur + +\section*{Questions traitées} + +\begin{tabular}{r c l} + \begin{tabular}{|c|c|c|c|c|c|} + \hline + Question ~ & ER & ~PR~ & QE & NT \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + \end{tabular} +& ~~ & + \begin{tabular}{|c|c|c|c|c|c|} + \hline + Question ~ & ER & ~PR~ & QE & NT \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + & & & & \\ + \hline + \end{tabular} \\ + + & & \\ + +ER : entièrement résolue & & PR : partiellement résolue \\ + +\smallskip + +QE : quelques éléments de réponse & & NT : non traitée +\end{tabular} + +~ + +\smallskip + +Remarque : il est possible de cocher entre les cases pour un cas intermédiaire. + +\section*{Evaluation qualitative de la solution} + +Donnez votre avis concernant la solution. Mettez notamment en valeur les points positifs (des idées +importantes, originales, etc.) et précisez ce qui aurait pu améliorer la solution. + +\vfill + +\textbf{Evaluation générale :} ~ $\square$ Excellente ~ $\square$ Bonne ~ $\square$ Suffisante ~ $\square$ Passable + +\newpage + +\section*{Erreurs et imprécisions} + +Listez ci-dessous les cinq erreurs et/ou imprécisions les plus importantes selon vous, par ordre d'importance, en précisant la +question concernée, la page, le paragraphe et le type de remarque. + +\bigskip + +1. Question \underline{~~~~} Page \underline{~~~~} Paragraphe \underline{~~~~} + +$\square$ Erreur majeure ~ $\square$ Erreur mineure ~ $\square$ Imprécision ~ $\square$ Autre : \underline{~~~~~~~~} + +Description : + +\vfill + +2. Question \underline{~~~~} Page \underline{~~~~} Paragraphe \underline{~~~~} + +$\square$ Erreur majeure ~ $\square$ Erreur mineure ~ $\square$ Imprécision ~ $\square$ Autre : \underline{~~~~~~~~} + +Description : + +\vfill + +3. Question \underline{~~~~} Page \underline{~~~~} Paragraphe \underline{~~~~} + +$\square$ Erreur majeure ~ $\square$ Erreur mineure ~ $\square$ Imprécision ~ $\square$ Autre : \underline{~~~~~~~~} + +Description : + +\vfill + +4. Question \underline{~~~~} Page \underline{~~~~} Paragraphe \underline{~~~~} + +$\square$ Erreur majeure ~ $\square$ Erreur mineure ~ $\square$ Imprécision ~ $\square$ Autre : \underline{~~~~~~~~} + +Description : + +\vfill + +5. Question \underline{~~~~} Page \underline{~~~~} Paragraphe \underline{~~~~} + +$\square$ Erreur majeure ~ $\square$ Erreur mineure ~ $\square$ Imprécision ~ $\square$ Autre : \underline{~~~~~~~~} + +Description : + +\vfill + +\section*{Remarques formelles (facultatif)} + +Donnez votre avis concernant la présentation de la solution (lisibilité, etc.). + +\vfill + + + +\end{document} diff --git a/static/Fiche_sanitaire.pdf b/static/Fiche_sanitaire.pdf new file mode 100644 index 0000000..b828b9d Binary files /dev/null and b/static/Fiche_sanitaire.pdf differ diff --git a/static/Instructions.tex b/static/Instructions.tex new file mode 100644 index 0000000..da293ef --- /dev/null +++ b/static/Instructions.tex @@ -0,0 +1,88 @@ +\documentclass[a4paper,french,11pt]{article} + +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{lmodern} +\usepackage[frenchb]{babel} + +\usepackage{fancyhdr} +\usepackage{graphicx} +\usepackage{amsmath} +\usepackage{amssymb} +%\usepackage{anyfontsize} +\usepackage{fancybox} +\usepackage{eso-pic,graphicx} +\usepackage{xcolor} +\usepackage{hyperref} + + +% Specials +\newcommand{\writingsep}{\vrule height 4ex width 0pt} + +% Page formating +\hoffset -1in +\voffset -1in +\textwidth 180 mm +\textheight 250 mm +\oddsidemargin 15mm +\evensidemargin 15mm +\pagestyle{fancy} + +% Headers and footers +\fancyfoot{} +\lhead{} +\rhead{} +\renewcommand{\headrulewidth}{0pt} +\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018} +\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.} + +\begin{document} + +\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{50pt}{50pt}{$\mathbb{TFJM}^2$}} + + + +\begin{center} +\Large \bf Instructions ({TOURNAMENT_NAME}) +\end{center} + +\section{Documents} +\subsection{Autorisation parentale} +Elle est nécessaire si l'élève est mineur au moment du tournoi (y compris si son anniversaire est pendant le tournoi). + +\subsection{Autorisation de prise de vue} +Si l'élève est mineur \textbf{au moment de la signature}, il convient de remplir l'autorisation pour les mineurs. En revanche, s'il est majeur \textbf{au moment de la signature}, il convient de remplir la fiche pour majeur. + +\subsection{Fiche sanitaire} +Elle est nécessaire si l'élève est mineur au moment du tournoi (y compris si son anniversaire est pendant le tournoi). + + +\section{Paiement} + +\subsection{Montant} +Les frais d'inscription sont fixés à {PRICE} euros. Vous devez vous en acquitter \textbf{avant le {END_PAYMENT_DATE} {YEAR}}. Si l'élève est boursier, il en est dispensé, vous devez alors fournir une copie de sa notification de bourse directement sur la plateforme \textbf{avant le {END_PAYMENT_DATE} {YEAR}}. + +\subsection{Procédure} + +Si le paiement de plusieurs élèves est fait en une seule opération, merci de contacter \href{mailto: contact@tfjm.org}{contact@tfjm.org} \textbf{avant le paiement} pour garantir l'identification de ce dernier + +\subsubsection*{Carte bancaire (uniquement les cartes françaises)} +Le paiement s'effectue en ligne via la plateforme à l'adresse : \url{https://www.helloasso.com/associations/animath/evenements/tfjm-2020} + +Vous devez impérativement indiquer dans le champ "Référence" la mention "TFJMpu" suivie des noms et prénoms \textbf{de l'élève}. + +\subsubsection*{Virement} +\textbf{Si vous ne pouvez pas utiliser le paiement par carte}, vous pouvez faire un virement sur le compte ci-dessous en indiquant bien dans le champ "motif" (ou autre champ propre à votre banque dont le contenu est communiqué au destinataire) la mention "TFJMpu" suivie des noms et prénoms \textbf{de l'élève}. + +IBAN FR76 1027 8065 0000 0206 4290 127 + +BIC CMCIFR2A + +\subsubsection*{Autre} + +Si aucune de ces procédures n'est possible pour vous, envoyez un mail à \href{mailto: contact@tfjm.org}{contact@tfjm.org} pour que nous trouvions une solution à vos difficultés. + + + + +\end{document} diff --git a/static/bootstrap_datepicker_plus/css/datepicker-widget.css b/static/bootstrap_datepicker_plus/css/datepicker-widget.css new file mode 100644 index 0000000..baeec50 --- /dev/null +++ b/static/bootstrap_datepicker_plus/css/datepicker-widget.css @@ -0,0 +1,121 @@ +@font-face { + font-family: 'Glyphicons Halflings'; + src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot'); + src: url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), + url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff2') format('woff2'), + url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff') format('woff'), + url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.ttf') format('truetype'), + url('//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); +} + +.glyphicon { + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; + font-style: normal; + font-weight: normal; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.glyphicon-time:before { + content: "\e023"; +} + +.glyphicon-chevron-left:before { + content: "\e079"; +} + +.glyphicon-chevron-right:before { + content: "\e080"; +} + +.glyphicon-chevron-up:before { + content: "\e113"; +} + +.glyphicon-chevron-down:before { + content: "\e114"; +} + +.glyphicon-calendar:before { + content: "\e109"; +} + +.glyphicon-screenshot:before { + content: "\e087"; +} + +.glyphicon-trash:before { + content: "\e020"; +} + +.glyphicon-remove:before { + content: "\e014"; +} + +.bootstrap-datetimepicker-widget .btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} + +.bootstrap-datetimepicker-widget.dropdown-menu { + position: absolute; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); +} + +.bootstrap-datetimepicker-widget .list-unstyled { + padding-left: 0; + list-style: none; +} + +.bootstrap-datetimepicker-widget .collapse { + display: none; +} + +.bootstrap-datetimepicker-widget .collapse.in { + display: block; +} + +/* fix for bootstrap4 */ +.bootstrap-datetimepicker-widget .table-condensed > thead > tr > th, +.bootstrap-datetimepicker-widget .table-condensed > tbody > tr > td, +.bootstrap-datetimepicker-widget .table-condensed > tfoot > tr > td { + padding: 5px; +} diff --git a/static/bootstrap_datepicker_plus/js/datepicker-widget.js b/static/bootstrap_datepicker_plus/js/datepicker-widget.js new file mode 100644 index 0000000..2288b46 --- /dev/null +++ b/static/bootstrap_datepicker_plus/js/datepicker-widget.js @@ -0,0 +1,55 @@ +jQuery(function ($) { + var datepickerDict = {}; + var isBootstrap4 = $.fn.collapse.Constructor.VERSION.split('.').shift() == "4"; + function fixMonthEndDate(e, picker) { + e.date && picker.val().length && picker.val(e.date.endOf('month').format('YYYY-MM-DD')); + } + $("[dp_config]:not([disabled])").each(function (i, element) { + var $element = $(element), data = {}; + try { + data = JSON.parse($element.attr('dp_config')); + } + catch (x) { } + if (data.id && data.options) { + data.$element = $element.datetimepicker(data.options); + data.datepickerdata = $element.data("DateTimePicker"); + datepickerDict[data.id] = data; + data.$element.next('.input-group-addon').on('click', function(){ + data.datepickerdata.show(); + }); + if(isBootstrap4){ + data.$element.on("dp.show", function (e) { + $('.collapse.in').addClass('show'); + }); + } + } + }); + $.each(datepickerDict, function (id, to_picker) { + if (to_picker.linked_to) { + var from_picker = datepickerDict[to_picker.linked_to]; + from_picker.datepickerdata.maxDate(to_picker.datepickerdata.date() || false); + to_picker.datepickerdata.minDate(from_picker.datepickerdata.date() || false); + from_picker.$element.on("dp.change", function (e) { + to_picker.datepickerdata.minDate(e.date || false); + }); + to_picker.$element.on("dp.change", function (e) { + if (to_picker.picker_type == 'MONTH') fixMonthEndDate(e, to_picker.$element); + from_picker.datepickerdata.maxDate(e.date || false); + }); + if (to_picker.picker_type == 'MONTH') { + to_picker.$element.on("dp.hide", function (e) { + fixMonthEndDate(e, to_picker.$element); + }); + fixMonthEndDate({ date: to_picker.datepickerdata.date() }, to_picker.$element); + } + } + }); + if(isBootstrap4) { + $('body').on('show.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){ + $(e.target).addClass('in'); + }); + $('body').on('hidden.bs.collapse','.bootstrap-datetimepicker-widget .collapse',function(e){ + $(e.target).removeClass('in'); + }); + } +}); diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..97757d3 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..699316b --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,114 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/static/logo_animath.png b/static/logo_animath.png new file mode 100644 index 0000000..da4533e Binary files /dev/null and b/static/logo_animath.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5c8d3ff --- /dev/null +++ b/static/style.css @@ -0,0 +1,47 @@ +html, body { + height: 100%; + margin: 0; +} + +:root { + --navbar-height: 32px; +} + +.container { + min-height: 78%; +} + +.inner { + margin: 20px; +} + +.alert { + text-align: justify; +} + + +footer .alert { + text-align: center; +} + +#navbar-logo { + height: var(--navbar-height); + display: block; +} + +ul .deroule { + display: none; + position: absolute; + background: #f8f9fa !important; + list-style-type: none; + padding: 20px; + z-index: 42; +} + +li:hover ul.deroule { + display:block; +} + +a.nav-link:hover { + background-color: #d8d9da; +} diff --git a/templates/400.html b/templates/400.html new file mode 100644 index 0000000..3560652 --- /dev/null +++ b/templates/400.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +

{% trans "Bad request" %}

+ {% blocktrans %}Sorry, your request was bad. Don't know what could be wrong. An email has been sent to webmasters with the details of the error. You can now drink a coke.{% endblocktrans %} +{% endblock %} \ No newline at end of file diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..317865f --- /dev/null +++ b/templates/403.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +

{% trans "Permission denied" %}

+ {% blocktrans %}You don't have the right to perform this request.{% endblocktrans %} + {% if exception %} +
+ {% trans "Exception message:" %} {{ exception }} +
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..8477f91 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +

{% trans "Page not found" %}

+ {% blocktrans %}The requested path {{ request_path }} was not found on the server.{% endblocktrans %} + {% if exception != "Resolver404" %} +
+ {% trans "Exception message:" %} {{ exception }} +
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..7cc0063 --- /dev/null +++ b/templates/500.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +

{% trans "Server error" %}

+ {% blocktrans %}Sorry, an error occurred when processing your request. An email has been sent to webmasters with the detail of the error, and this will be fixed soon. You can now drink a beer.{% endblocktrans %} +{% endblock %} diff --git a/templates/amount_input.html b/templates/amount_input.html new file mode 100644 index 0000000..6ef4a53 --- /dev/null +++ b/templates/amount_input.html @@ -0,0 +1,11 @@ +
+ +
+ +
+
\ No newline at end of file diff --git a/templates/autocomplete_model.html b/templates/autocomplete_model.html new file mode 100644 index 0000000..2236c6e --- /dev/null +++ b/templates/autocomplete_model.html @@ -0,0 +1,9 @@ + + +
    +
diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d52e7be --- /dev/null +++ b/templates/base.html @@ -0,0 +1,234 @@ +{% load static i18n static getconfig %} + + + + + + + + {% block title %}{{ title }}{% endblock title %} - Inscription au TFJM² + + + + {# Favicon #} + + + {% if no_cache %} + + {% endif %} + + {# Bootstrap CSS #} + + + + + {# Custom CSS #} + + + {# JQuery, Bootstrap and Turbolinks JavaScript #} + + + + + + {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} + {% if form.media %} + {{ form.media }} + {% endif %} + + + + {% block extracss %}{% endblock %} + + +
+ +
+ {% block contenttitle %}

{{ title }}

{% endblock %} +
+ {% block content %} +

Default content...

+ {% endblock content %} +
+
+ +
+
+
+
+
+ + 𝕋𝔽𝕁𝕄² — + Nous contacter — + + {% csrf_token %} + + +
+
+
+ Ce site a été conçu pour Animath, avec amour et passion. Il est récent et il est possible que + certaines pages ne fonctionnent pas correctement. Si vous remarquez des bugs, merci de les signaler + à + l'adresse contact@tfjm.org.
+ © {{ "TFJM_YEAR"|get_env }} Tournoi Français des Jeunes Mathématiciennes et Mathématiciens +
+ +
+
+
+ + + +{% block extrajavascript %} +{% endblock extrajavascript %} + + diff --git a/templates/bootstrap_datepicker_plus/date_picker.html b/templates/bootstrap_datepicker_plus/date_picker.html new file mode 100644 index 0000000..67a11df --- /dev/null +++ b/templates/bootstrap_datepicker_plus/date_picker.html @@ -0,0 +1,6 @@ +
+ {% include "bootstrap_datepicker_plus/input.html" %} +
+
+
+
diff --git a/templates/bootstrap_datepicker_plus/input.html b/templates/bootstrap_datepicker_plus/input.html new file mode 100644 index 0000000..b2f8c40 --- /dev/null +++ b/templates/bootstrap_datepicker_plus/input.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/templates/bootstrap_datepicker_plus/time_picker.html b/templates/bootstrap_datepicker_plus/time_picker.html new file mode 100644 index 0000000..2bd509a --- /dev/null +++ b/templates/bootstrap_datepicker_plus/time_picker.html @@ -0,0 +1,6 @@ +
+ {% include "bootstrap_datepicker_plus/input.html" %} +
+
+
+
diff --git a/templates/colorfield/color.html b/templates/colorfield/color.html new file mode 100755 index 0000000..2743359 --- /dev/null +++ b/templates/colorfield/color.html @@ -0,0 +1,8 @@ + diff --git a/templates/django_filters/rest_framework/crispy_form.html b/templates/django_filters/rest_framework/crispy_form.html new file mode 100644 index 0000000..171767c --- /dev/null +++ b/templates/django_filters/rest_framework/crispy_form.html @@ -0,0 +1,5 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +

{% trans "Field filters" %}

+{% crispy filter.form %} diff --git a/templates/django_filters/rest_framework/form.html b/templates/django_filters/rest_framework/form.html new file mode 100644 index 0000000..b116e35 --- /dev/null +++ b/templates/django_filters/rest_framework/form.html @@ -0,0 +1,6 @@ +{% load i18n %} +

{% trans "Field filters" %}

+
+ {{ filter.form.as_p }} + +
diff --git a/templates/django_filters/widgets/multiwidget.html b/templates/django_filters/widgets/multiwidget.html new file mode 100644 index 0000000..089ddb2 --- /dev/null +++ b/templates/django_filters/widgets/multiwidget.html @@ -0,0 +1 @@ +{% for widget in widget.subwidgets %}{% include widget.template_name %}{% if forloop.first %}-{% endif %}{% endfor %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9455dc1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% load getconfig %} + +{% block content %} + {% autoescape off %} + {{ "index_page"|get_config|safe }} + {% endautoescape %} +{% endblock %} diff --git a/templates/mail_templates/add_organizer.html b/templates/mail_templates/add_organizer.html new file mode 100644 index 0000000..06cc500 --- /dev/null +++ b/templates/mail_templates/add_organizer.html @@ -0,0 +1,20 @@ + + + + + Organisateur du TFJM² + + +Bonjour {{ user }},
+
+Vous recevez ce message (envoyé automatiquement) car vous êtes organisateur d'un des tournois du TFJM2.

+Un compte organisateur vous a été créé par l'un des administrateurs. Avant de vous connecter, vous devez réinitialiser votre +mot de passe sur le lien suivant : https://inscription.tfjm.org{% url "password_reset" %}. +
+Une fois le mot de passe changé, vous pourrez vous connecter sur la plateforme.
+
+Merci beaucoup pour votre aide !
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/add_organizer.txt b/templates/mail_templates/add_organizer.txt new file mode 100644 index 0000000..2a4cde4 --- /dev/null +++ b/templates/mail_templates/add_organizer.txt @@ -0,0 +1,12 @@ +Bonjour {{ user }}, + +Vous recevez ce message (envoyé automatiquement) car vous êtes organisateur d'un des tournois du TFJM². + +Un compte organisateur vous a été créé par l'un des administrateurs. Avant de vous connecter, vous devez réinitialiser votre +mot de passe sur le lien suivant : https://inscription.tfjm.org{% url "password_reset" %}. + +Une fois le mot de passe changé, vous pourrez vous connecter sur la plateforme : https://inscription.tfjm.org{% url "login" %}. + +Merci beaucoup pour votre aide ! + +Le comité national d'organisation du TFJM² diff --git a/templates/mail_templates/add_organizer_for_tournament.html b/templates/mail_templates/add_organizer_for_tournament.html new file mode 100644 index 0000000..ad9aa90 --- /dev/null +++ b/templates/mail_templates/add_organizer_for_tournament.html @@ -0,0 +1,18 @@ + + + + + + Organisateur du tournoi de {TOURNAMENT_NAME} – TFJM² + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous venez d'être promu organisateur du tournoi {TOURNAMENT_NAME} du TFJM2 {YEAR}.
+Ce message vous a été envoyé automatiquement. En cas de problème, merci de répondre à ce message. +
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/add_team.html b/templates/mail_templates/add_team.html new file mode 100644 index 0000000..bb69db0 --- /dev/null +++ b/templates/mail_templates/add_team.html @@ -0,0 +1,16 @@ + + + + + Nouvelle équipe TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous venez de créer l'équipe « {TEAM_NAME} » ({TRIGRAM}) pour le TFJM2 de {TOURNAMENT_NAME} et nous vous en remercions.
+Afin de permettre aux autres membres de votre équipe de vous rejoindre, veuillez leur transmettre le code d'accès : +{ACCESS_CODE}
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/change_email_address.html b/templates/mail_templates/change_email_address.html new file mode 100644 index 0000000..d04ed90 --- /dev/null +++ b/templates/mail_templates/change_email_address.html @@ -0,0 +1,16 @@ + + + + + + Changement d'adresse e-mail – TFJM² + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous venez de changer votre adresse e-mail. Veuillez désormais la confirmer en cliquant ici : {URL_BASE}/confirmer_mail/{TOKEN}
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/change_password.html b/templates/mail_templates/change_password.html new file mode 100644 index 0000000..673e80f --- /dev/null +++ b/templates/mail_templates/change_password.html @@ -0,0 +1,18 @@ + + + + + Mot de passe changé – TFJM² + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Nous vous informons que votre mot de passe vient d'être modifié. Si vous n'êtes pas à l'origine de cette manipulation, +veuillez immédiatement vérifier vos accès à votre boîte mail et changer votre mot de passe sur la plateforme +d'inscription.
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/confirm_email.html b/templates/mail_templates/confirm_email.html new file mode 100644 index 0000000..d247377 --- /dev/null +++ b/templates/mail_templates/confirm_email.html @@ -0,0 +1,18 @@ + + + + + + Inscription au TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous êtes inscrit au TFJM2 {YEAR} et nous vous en remercions.
+Pour valider votre adresse e-mail, veuillez cliquer sur le lien : {URL_BASE}/confirmer_mail/{TOKEN}
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/forgotten_password.html b/templates/mail_templates/forgotten_password.html new file mode 100644 index 0000000..717cc8c --- /dev/null +++ b/templates/mail_templates/forgotten_password.html @@ -0,0 +1,20 @@ + + + + + + Mot de passe oublié – TFJM² + + +Bonjour,
+
+Vous avez indiqué avoir oublié votre mot de passe. Veuillez cliquer ici pour le réinitialiser : {URL_BASE}/connexion/reinitialiser_mdp/{TOKEN}
+
+Si vous n'êtes pas à l'origine de cette manipulation, vous pouvez ignorer ce message.
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/join_team.html b/templates/mail_templates/join_team.html new file mode 100644 index 0000000..d5628c0 --- /dev/null +++ b/templates/mail_templates/join_team.html @@ -0,0 +1,17 @@ + + + + + Équipe rejointe – TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous venez de rejoindre l'équipe « {TEAM_NAME} » ({TRIGRAM}) pour le TFJM² de {TOURNAMENT_NAME} et nous vous en +remercions.
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/register.html b/templates/mail_templates/register.html new file mode 100644 index 0000000..bc4123b --- /dev/null +++ b/templates/mail_templates/register.html @@ -0,0 +1,16 @@ + + + + + + Inscription au TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Vous venez de vous inscrire au TFJM2 {YEAR} et nous vous en remercions.
+Pour valider votre adresse e-mail, veuillez cliquer sur le lien : {URL_BASE}/confirmer_mail/{TOKEN}
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/request_payment_validation.html b/templates/mail_templates/request_payment_validation.html new file mode 100644 index 0000000..913e490 --- /dev/null +++ b/templates/mail_templates/request_payment_validation.html @@ -0,0 +1,26 @@ + + + + + + Demande de validation de paiement pour le TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+{USER_FIRST_NAME} {USER_SURNAME} de l'équipe {TEAM_NAME} ({TRIGRAM}) annonce avoir réglé sa participation pour le tournoi {TOURNAMENT_NAME}. +Les informations suivantes ont été communiquées :

+Équipe : {TEAM_NAME} ({TRIGRAM})
+Tournoi : {TOURNAMENT_NAME}
+Moyen de paiement : {PAYMENT_METHOD}
+Montant : {AMOUNT} €
+Informations sur le paiement : {PAYMENT_INFOS}
+
+Vous pouvez désormais vérifier ces informations, puis valider (ou non) le paiement sur +la page associée à ce participant. +
+Avec toute notre bienveillance, +
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/request_validation.html b/templates/mail_templates/request_validation.html new file mode 100644 index 0000000..7a05122 --- /dev/null +++ b/templates/mail_templates/request_validation.html @@ -0,0 +1,19 @@ + + + + + + Demande de validation - TFJM² + + +Bonjour {{ user }},
+
+L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer au tournoi +{{ tournament }} du TFJM². Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe : +https://inscription.tfjm.org{% url "tournament:team_detail" pk=team.pk %}
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/request_validation.txt b/templates/mail_templates/request_validation.txt new file mode 100644 index 0000000..88d463b --- /dev/null +++ b/templates/mail_templates/request_validation.txt @@ -0,0 +1,9 @@ +Bonjour {{ user }}, + +L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer au tournoi +{{ tournament }} du TFJM². Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe : +https://inscription.tfjm.org{% url "tournament:team_detail" pk=team.pk %}. + +Avec toute notre bienveillance, + +Le comité national d'organisation du TFJM² diff --git a/templates/mail_templates/select_for_final.html b/templates/mail_templates/select_for_final.html new file mode 100644 index 0000000..383b527 --- /dev/null +++ b/templates/mail_templates/select_for_final.html @@ -0,0 +1,21 @@ + + + + + Sélection pour la finale - TFJM² + + +Bonjour {{ user }},
+
+Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est sélectionnée pour la finale nationale !
+
+La finale aura lieu du {{ final.date_start }} au {{ final.date_end }}. Vous pouvez peaufiner vos solutions +si vous le souhaitez jusqu'au {{ final.date_solutions }}.
+
+Bravo encore !
+
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM² + + \ No newline at end of file diff --git a/templates/mail_templates/select_for_final.txt b/templates/mail_templates/select_for_final.txt new file mode 100644 index 0000000..a000c22 --- /dev/null +++ b/templates/mail_templates/select_for_final.txt @@ -0,0 +1,12 @@ +Bonjour {{ user }}, + +Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est sélectionnée pour la finale nationale ! + +La finale aura lieu du {{ final.date_start }} au {{ final.date_end }}. Vous pouvez peaufiner vos solutions +si vous le souhaitez jusqu'au {{ final.date_solutions }}. + +Bravo encore ! + +Avec toute notre bienveillance, + +Le comité national d'organisation du TFJM² diff --git a/templates/mail_templates/unvalidate_payment.html b/templates/mail_templates/unvalidate_payment.html new file mode 100644 index 0000000..7273282 --- /dev/null +++ b/templates/mail_templates/unvalidate_payment.html @@ -0,0 +1,24 @@ + + + + + + Non-validation du paiement pour le TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Votre paiement pour le TFJM² {YEAR} a malheureusement été rejeté. Pour rappel, vous aviez fourni ces informations :

+Équipe : {TEAM_NAME} ({TRIGRAM})
+Tournoi : {TOURNAMENT_NAME}
+Moyen de paiement : {PAYMENT_METHOD}
+Montant : {AMOUNT} €
+Informations sur le paiement : {PAYMENT_INFOS}
+
+{MESSAGE} +
+Avec toute notre bienveillance, +
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/unvalidate_team.html b/templates/mail_templates/unvalidate_team.html new file mode 100644 index 0000000..1ba3ce8 --- /dev/null +++ b/templates/mail_templates/unvalidate_team.html @@ -0,0 +1,26 @@ + + + + + Équipe non validée – TFJM² + + +Bonjour {{ user }},
+
+Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos autorisations sont correctes. +{% if message %} +

+ Le CNO vous adresse le message suivant : +

+ {{ message }} +
+

+{% endif %} +
+N'hésitez pas à nous contacter à l'adresse contact@tfjm.org pour plus d'informations. +
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/unvalidate_team.txt b/templates/mail_templates/unvalidate_team.txt new file mode 100644 index 0000000..88c1440 --- /dev/null +++ b/templates/mail_templates/unvalidate_team.txt @@ -0,0 +1,15 @@ +Bonjour {{ user }}, + +Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos autorisations sont correctes. + +{% if message %} +Le CNO vous adresse le message suivant : + +{{ message }} +{% endif %} + +N'hésitez pas à nous contacter à l'adresse contact@tfjm.org pour plus d'informations. + +Avec toute notre bienveillance, + +Le comité national d'organisation du TFJM² diff --git a/templates/mail_templates/validate_payment.html b/templates/mail_templates/validate_payment.html new file mode 100644 index 0000000..2743f17 --- /dev/null +++ b/templates/mail_templates/validate_payment.html @@ -0,0 +1,24 @@ + + + + + + Validation du paiement pour le TFJM² {YEAR} + + +Bonjour {FIRST_NAME} {SURNAME},
+
+Votre paiement pour le TFJM² {YEAR} a bien été validé. Pour rappel, vous aviez fourni ces informations :

+Équipe : {TEAM_NAME} ({TRIGRAM})
+Tournoi : {TOURNAMENT_NAME}
+Moyen de paiement : {PAYMENT_METHOD}
+Montant : {AMOUNT} €
+Informations sur le paiement : {PAYMENT_INFOS}
+
+{MESSAGE} +
+Avec toute notre bienveillance, +
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/validate_team.html b/templates/mail_templates/validate_team.html new file mode 100644 index 0000000..ef23e85 --- /dev/null +++ b/templates/mail_templates/validate_team.html @@ -0,0 +1,25 @@ + + + + + Équipe validée – TFJM² {YEAR} + + +Bonjour {{ user }},
+
+Félicitations ! Votre équipe « {{ team }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte à travailler sur +vos problèmes et publier vos solutions sur la plateforme. +{% if message %} +

+ Le CNO vous adresse le message suivant : +

+ {{ message }} +
+

+{% endif %} +
+Avec toute notre bienveillance,
+
+Le comité national d'organisation du TFJM2 + + diff --git a/templates/mail_templates/validate_team.txt b/templates/mail_templates/validate_team.txt new file mode 100644 index 0000000..8b4c95e --- /dev/null +++ b/templates/mail_templates/validate_team.txt @@ -0,0 +1,13 @@ +Bonjour {{ user }}, + +Félicitations ! Votre équipe « {{ team }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte à travailler sur +vos problèmes et publier vos solutions sur la plateforme. + +{% if message %} +Le CNO vous adresse le message suivant : +{{ message }} +{% endif %} + +Avec toute notre bienveillance, + +Le comité national d'organisation du TFJM² diff --git a/templates/member/my_account.html b/templates/member/my_account.html new file mode 100644 index 0000000..884bf2b --- /dev/null +++ b/templates/member/my_account.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters %} + +{% block content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+ +
+ + {% trans "Update my password" %} +{% endblock %} diff --git a/templates/member/profile_list.html b/templates/member/profile_list.html new file mode 100644 index 0000000..7a6f85f --- /dev/null +++ b/templates/member/profile_list.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% load django_tables2 i18n %} + +{% block content %} + {% render_table table %} + {% if type == "organizers" and user.admin %} +
+ {% trans "Add an organizer" %} + {% endif %} +{% endblock %} diff --git a/templates/member/tfjmuser_detail.html b/templates/member/tfjmuser_detail.html new file mode 100644 index 0000000..26e63b4 --- /dev/null +++ b/templates/member/tfjmuser_detail.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% load getconfig i18n django_tables2 static %} + +{% block content %} +
+
+

{{ tfjmuser }}

+
+
+
+
{% trans 'role'|capfirst %}
+
{{ tfjmuser.get_role_display }}
+ + {% if tfjmuser.team %} +
{% trans 'team'|capfirst %}
+
{{ tfjmuser.team }}
+ {% endif %} + + {% if tfjmuser.birth_date %} +
{% trans 'birth date'|capfirst %}
+
{{ tfjmuser.birth_date }}
+ {% endif %} + + {% if tfjmuser.participates %} +
{% trans 'gender'|capfirst %}
+
{{ tfjmuser.get_gender_display }}
+ {% endif %} + + {% if tfjmuser.address %} +
{% trans 'address'|capfirst %}
+
{{ tfjmuser.address }}, {{ tfjmuser.postal_code }}, {{ tfjmuser.city }}{% if tfjmuser.country != "France" %}, {{ tfjmuser.country }}{% endif %}
+ {% endif %} + +
{% trans 'email'|capfirst %}
+
{{ tfjmuser.email }}
+ + {% if tfjmuser.phone_number %} +
{% trans 'phone number'|capfirst %}
+
{{ tfjmuser.phone_number }}
+ {% endif %} + + {% if tfjmuser.role == '3participant' %} +
{% trans 'school'|capfirst %}
+
{{ tfjmuser.school }}
+ +
{% trans 'class'|capfirst %}
+
{{ tfjmuser.get_student_class_display }}
+ + {% if tfjmuser.responsible_name %} +
{% trans 'responsible name'|capfirst %}
+
{{ tfjmuser.responsible_name }}
+ {% endif %} + + {% if tfjmuser.responsible_phone %} +
{% trans 'responsible phone'|capfirst %}
+
{{ tfjmuser.responsible_phone }}
+ {% endif %} + + {% if tfjmuser.responsible_email %} +
{% trans 'responsible email'|capfirst %}
+
{{ tfjmuser.responsible_email }}
+ {% endif %} + {% endif %} + + {% if tfjmuser.role != '3participant' %} +
{% trans 'description'|capfirst %}
+
{{ tfjmuser.description|default_if_none:"" }}
+ {% endif %} +
+
+
+ +
+ +

{% trans "Documents" %}

+ + {# TODO Display documents #} + + {% if request.user.is_superuser %} +
+
+ {% csrf_token %} + +
+ {% endif %} +{% endblock %} diff --git a/templates/registration/email_validation_complete.html b/templates/registration/email_validation_complete.html new file mode 100644 index 0000000..b54432f --- /dev/null +++ b/templates/registration/email_validation_complete.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + {% if validlink %} + {% trans "Your email have successfully been validated." %} + {% if user_object.profile.registration_valid %} + {% blocktrans %}You can now log in.{% endblocktrans %} + {% else %} + {% trans "You must pay now your membership in the Kfet to complete your registration." %} + {% endif %} + {% else %} + {% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %} + {% endif %} +{% endblock %} diff --git a/templates/registration/email_validation_email_sent.html b/templates/registration/email_validation_email_sent.html new file mode 100644 index 0000000..bd4cf8d --- /dev/null +++ b/templates/registration/email_validation_email_sent.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +

Account Activation

+ +An email has been sent. Please click on the link to activate your account. +{% endblock %} \ No newline at end of file diff --git a/templates/registration/logged_out.html b/templates/registration/logged_out.html new file mode 100644 index 0000000..3b044b7 --- /dev/null +++ b/templates/registration/logged_out.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block content %} +

{% trans "Thanks for spending some quality time with the Web site today." %}

+

{% trans 'Log in again' %}

+{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..64c5c26 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-2.0-or-later +{% endcomment %} +{% load i18n crispy_forms_filters %} + +{% block title %}{% trans "Log in" %}{% endblock %} +{% block contenttitle %}

{% trans "Log in" %}

{% endblock %} + +{% block content %} + {% if user.is_authenticated %} +

+ {% blocktrans trimmed %} + You are authenticated as {{ user }}, but are not authorized to + access this page. Would you like to login to a different account? + {% endblocktrans %} +

+ {% endif %} +
+ {% csrf_token %} + {{ form | crispy }} + + {% trans 'Forgotten your password or username?' %} +
+{% endblock %} diff --git a/templates/registration/mails/email_validation_email.html b/templates/registration/mails/email_validation_email.html new file mode 100644 index 0000000..577c122 --- /dev/null +++ b/templates/registration/mails/email_validation_email.html @@ -0,0 +1,15 @@ +{% load i18n %} + +{% trans "Hi" %} {{ user.username }}, + +{% trans "You recently registered on the Note Kfet. Please click on the link below to confirm your registration." %} + +https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %} + +{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %} + +{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %} + +{% trans "Thanks" %}, + +{% trans "The Note Kfet team." %} diff --git a/templates/registration/password_change_done.html b/templates/registration/password_change_done.html new file mode 100644 index 0000000..150a00e --- /dev/null +++ b/templates/registration/password_change_done.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block content %} +

{% trans 'Your password was changed.' %}

+{% endblock %} diff --git a/templates/registration/password_change_form.html b/templates/registration/password_change_form.html new file mode 100644 index 0000000..01133e4 --- /dev/null +++ b/templates/registration/password_change_form.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n crispy_forms_tags %} + +{% block content %} +
{% csrf_token %} +

{% trans "Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly." %}

+ {{ form | crispy }} + +
+{% endblock %} \ No newline at end of file diff --git a/templates/registration/password_reset_complete.html b/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..bb91a3c --- /dev/null +++ b/templates/registration/password_reset_complete.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block content %} +

{% trans "Your password has been set. You may go ahead and log in now." %}

+

+ {% trans 'Log in' %} +

+{% endblock %} diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..5db0e81 --- /dev/null +++ b/templates/registration/password_reset_confirm.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n crispy_forms_tags %} + +{% block content %} + {% if validlink %} +

{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}

+
{% csrf_token %} + {{ form | crispy }} + +
+ {% else %} +

{% trans "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}

+ {% endif %} +{% endblock %} diff --git a/templates/registration/password_reset_done.html b/templates/registration/password_reset_done.html new file mode 100644 index 0000000..a215ab9 --- /dev/null +++ b/templates/registration/password_reset_done.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block content %} +

{% trans "We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly." %}

+

{% trans "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}

+{% endblock %} diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..61adaa9 --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n crispy_forms_tags %} + +{% block content %} +

{% trans "Forgotten your password? Enter your email address below, and we'll email instructions for setting a new one." %}

+
{% csrf_token %} + {{ form | crispy }} + +
+{% endblock %} diff --git a/templates/registration/signup.html b/templates/registration/signup.html new file mode 100644 index 0000000..ed100d0 --- /dev/null +++ b/templates/registration/signup.html @@ -0,0 +1,17 @@ + +{% extends 'base.html' %} +{% load crispy_forms_filters %} +{% load i18n %} +{% block title %}{% trans "Sign up" %}{% endblock %} + +{% block content %} +

{% trans "Sign up" %}

+ +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/templates/tournament/add_organizer.html b/templates/tournament/add_organizer.html new file mode 100644 index 0000000..21daee8 --- /dev/null +++ b/templates/tournament/add_organizer.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters %} + +{% block content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/templates/tournament/pool_detail.html b/templates/tournament/pool_detail.html new file mode 100644 index 0000000..d20c2b8 --- /dev/null +++ b/templates/tournament/pool_detail.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% load getconfig i18n django_tables2 static %} + +{% block content %} +
+
+

{{ title }}

+
+
+
+
{% trans 'juries'|capfirst %}
+
{{ pool.juries.all|join:", " }}
+ +
{% trans 'teams'|capfirst %}
+
{{ pool.teams.all|join:", " }}
+ +
{% trans 'round'|capfirst %}
+
{{ pool.round }}
+ +
{% trans 'tournament'|capfirst %}
+
{{ pool.tournament }}
+
+
+
+ +
+ +
+
+

{% trans "Solutions" %}

+
+
+ {% if pool.round == 2 %} +
+ {% trans "Solutions will be available here for teams from:" %} {{ pool.tournament.date_solutions_2 }} +
+ {% endif %} + +
    + {% for solution in pool.solutions.all %} +
  • {{ solution }}
  • + {% endfor %} +
+
+ +
+
+ +
+
+

{% trans "Syntheses" %}

+
+
+
+ {% trans "Templates for syntheses are available here:" %} + PDFTEX +
+ {% if user.organizes or not user.is_authenticated %} +
    + {% for synthesis in pool.syntheses.all %} +
  • {{ synthesis }}
  • + {% endfor %} +
+ + {% endif %} +
+
+ +
+ + + + {% if user.organizes or not user.is_authenticated %} +
+
+ {% trans "Give this link to juries to access this page (warning: should stay confidential and only given to juries of this pool):" %}
+ + https://{{ request.get_host }}{% url "tournament:pool_detail" pk=pool.pk %}?extra_access_token={{ pool.extra_access_token }} +
+ {% endif %} +{% endblock %} diff --git a/templates/tournament/pool_form.html b/templates/tournament/pool_form.html new file mode 100644 index 0000000..21daee8 --- /dev/null +++ b/templates/tournament/pool_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters %} + +{% block content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/templates/tournament/pool_list.html b/templates/tournament/pool_list.html new file mode 100644 index 0000000..9a15348 --- /dev/null +++ b/templates/tournament/pool_list.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% load i18n django_tables2 %} + +{% block content %} + {% render_table table %} + + {% if user.admin %} +
+ + {% endif %} +{% endblock %} diff --git a/templates/tournament/solutions_list.html b/templates/tournament/solutions_list.html new file mode 100644 index 0000000..6005d11 --- /dev/null +++ b/templates/tournament/solutions_list.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters django_tables2 %} + +{% block content %} + {% if form %} + {% if now < user.team.future_tournament.date_solutions %} +
+ {% blocktrans with deadline=user.team.future_tournament.date_solutions %}You can upload your solutions until {{ deadline }}.{% endblocktrans %} +
+ {% else %} +
+ {% if now < real_deadline %} + {% trans "The deadline to send your solutions is reached. However, you have an extra time of 30 minutes to send your papers, no panic :)" %} + {% else %} + {% trans "You can't upload your solutions anymore." %} + {% endif %} +
+ {% endif %} + +
+ {% csrf_token %} + {{ form|crispy }} + +
+
+ {% endif %} + {% render_table table %} +{% endblock %} diff --git a/templates/tournament/solutions_orga_list.html b/templates/tournament/solutions_orga_list.html new file mode 100644 index 0000000..fe83b4f --- /dev/null +++ b/templates/tournament/solutions_orga_list.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% load i18n django_tables2 %} + +{% block content %} + {% render_table table %} + +
+ +
+ {% csrf_token %} +
+ {% for tournament in tournaments.all %} + + {% endfor %} +
+
+{% endblock %} diff --git a/templates/tournament/syntheses_list.html b/templates/tournament/syntheses_list.html new file mode 100644 index 0000000..b1cf355 --- /dev/null +++ b/templates/tournament/syntheses_list.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters django_tables2 static %} + +{% block content %} +
+ {% trans "Templates for syntheses are available here:" %} + PDFTEX +
+ + {% if form %} + {% if now < user.team.future_tournament.date_syntheses %} +
+ {% blocktrans with deadline=user.team.future_tournament.date_syntheses round=1 %}You can upload your syntheses for round {{ round }} until {{ deadline }}.{% endblocktrans %} +
+ {% elif now < real_deadline_1 %} +
+ {% blocktrans with round=1 %}The deadline to send your syntheses for the round {{ round }} is reached. However, you have an extra time of 30 minutes to send your papers, no panic :){% endblocktrans %} +
+ {% elif now < user.team.future_tournament.date_solutions_2 %} +
+ {% blocktrans with round=1 %}You can't upload your syntheses for the round {{ round }} anymore.{% endblocktrans %} +
+ {% elif now < user.team.future_tournament.date_syntheses_2 %} +
+ {% blocktrans with deadline=user.team.future_tournament.date_syntheses_2 round=2 %}You can upload your syntheses for round {{ round }} until {{ deadline }}.{% endblocktrans %} +
+ {% elif now < real_deadline_2 %} +
+ {% blocktrans with round=2 %}The deadline to send your syntheses for the round {{ round }} is reached. However, you have an extra time of 30 minutes to send your papers, no panic :){% endblocktrans %} +
+ {% else %} +
+ {% blocktrans with round=2 %}You can't upload your syntheses for the round {{ round }} anymore.{% endblocktrans %} +
+ {% endif %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+
+ {% endif %} + {% render_table table %} +{% endblock %} diff --git a/templates/tournament/syntheses_orga_list.html b/templates/tournament/syntheses_orga_list.html new file mode 100644 index 0000000..fe83b4f --- /dev/null +++ b/templates/tournament/syntheses_orga_list.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% load i18n django_tables2 %} + +{% block content %} + {% render_table table %} + +
+ +
+ {% csrf_token %} +
+ {% for tournament in tournaments.all %} + + {% endfor %} +
+
+{% endblock %} diff --git a/templates/tournament/team_detail.html b/templates/tournament/team_detail.html new file mode 100644 index 0000000..21dbada --- /dev/null +++ b/templates/tournament/team_detail.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% load getconfig i18n django_tables2 static %} + +{% block content %} +
+
+

{% trans "Team" %} {{ team.name }}

+
+
+
+
{% trans 'name'|capfirst %}
+
{{ team.name }}
+ +
{% trans 'trigram'|capfirst %}
+
{{ team.trigram }}
+ +
{% trans 'access code'|capfirst %}
+
{{ team.access_code }}
+ +
{% trans 'tournament'|capfirst %}
+
{{ team.tournament }}
+ +
{% trans 'coachs'|capfirst %}
+
{% autoescape off %}{{ team.linked_coaches|join:", " }}{% endautoescape %}
+ +
{% trans 'participants'|capfirst %}
+
+ {% autoescape off %}{{ team.linked_participants|join:", " }}{% endautoescape %}
+ +
{% trans 'validation status'|capfirst %}
+
{{ team.get_validation_status_display }}
+
+
+ + {% if user.is_authenticated and user.admin %} + + {% endif %} + + {% if user.admin or user in team.tournament.organizers.all or team == user.team %} + + {% endif %} +
+ + {% if user.participates and team.invalid %} +
+ {% if team.can_validate %} +
+ {% csrf_token %} + + +
+ Attention ! Une fois votre équipe validée, vous ne pourrez plus modifier le nom + de l'équipe, le trigramme ou la composition de l'équipe. +
+ +
+ {% else %} +
+ Pour demander à valider votre équipe, vous devez avoir au moins un encadrant, quatre participants + et soumis une autorisation de droit à l'image, une fiche sanitaire et une autorisation + parentale (si besoin) par participant, ainsi qu'une lettre de motivation à transmettre aux + organisateurs. + Les encadrants doivent également fournir une autorisation de droit à l'image. +
+ {% endif %} +
+
+ En raison du changement de format du TFJM² 2020, il n'y a plus de document obligatoire à envoyer. Les + autorisations + précédemment envoyées ont été détruites. Seules les lettres de motivation ont été conservées, mais leur + envoi + n'est plus obligatoire. +
+ {% endif %} + + {% if team.waiting %} +
+
+ {% trans "The team is waiting about validation." %} +
+ + {% if user.admin %} +
+ {% csrf_token %} +
+ + +
+ +
+
+ + +
+
+
+ {% endif %} + {% endif %} + +
+ +

{% trans "Documents" %}

+ + {% if team.motivation_letters.count %} +
+ {% blocktrans %}Motivation letter:{% endblocktrans %} + {% trans "Download" %} +
+ {% endif %} + + {% if team.solutions.count %} +
+ +
+
+
+ {% csrf_token %} + +
+
+ {% endif %} +{% endblock %} diff --git a/templates/tournament/team_form.html b/templates/tournament/team_form.html new file mode 100644 index 0000000..21daee8 --- /dev/null +++ b/templates/tournament/team_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters %} + +{% block content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/templates/tournament/tournament_detail.html b/templates/tournament/tournament_detail.html new file mode 100644 index 0000000..2c3b617 --- /dev/null +++ b/templates/tournament/tournament_detail.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% load getconfig i18n django_tables2 %} + +{% block content %} +
+
+

{{ title }}

+
+
+
+
{% trans 'organizers'|capfirst %}
+
{% autoescape off %}{{ tournament.linked_organizers|join:", " }}{% endautoescape %}
+ +
{% trans 'size'|capfirst %}
+
{{ tournament.size }}
+ +
{% trans 'place'|capfirst %}
+
{{ tournament.place }}
+ +
{% trans 'price'|capfirst %}
+
{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}
+ +
{% trans 'dates'|capfirst %}
+
{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}
+ +
{% trans 'date of registration closing'|capfirst %}
+
{{ tournament.date_inscription }}
+ +
{% trans 'date of maximal solution submission'|capfirst %}
+
{{ tournament.date_solutions }}
+ +
{% trans 'date of maximal syntheses submission for the first round'|capfirst %}
+
{{ tournament.date_syntheses }}
+ +
{% trans 'date when solutions of round 2 are available'|capfirst %}
+
{{ tournament.date_solutions_2 }}
+ +
{% trans 'date of maximal syntheses submission for the second round'|capfirst %}
+
{{ tournament.date_syntheses_2 }}
+ +
{% trans 'description'|capfirst %}
+
{{ tournament.description }}
+
+ + {% if user.is_authenticated and user.admin %} + + {% endif %} +
+ + {% if user.admin or user in tournament.organizers.all %} + + {% endif %} +
+ +
+ +

{% trans "Teams" %}

+
+ {% render_table teams %} +
+{% endblock %} diff --git a/templates/tournament/tournament_form.html b/templates/tournament/tournament_form.html new file mode 100644 index 0000000..21daee8 --- /dev/null +++ b/templates/tournament/tournament_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% load i18n crispy_forms_filters %} + +{% block content %} +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/templates/tournament/tournament_list.html b/templates/tournament/tournament_list.html new file mode 100644 index 0000000..6a3479a --- /dev/null +++ b/templates/tournament/tournament_list.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% load django_tables2 getconfig i18n %} + +{% block content %} + {% if user.is_authenticated and user.admin %} + + {% endif %} + {% render_table table %} + {% if user.is_authenticated and user.admin %} +
+ {% trans "Add a tournament" %} + {% endif %} +{% endblock %} diff --git a/tfjm/__init__.py b/tfjm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tfjm/asgi.py b/tfjm/asgi.py new file mode 100644 index 0000000..a75729d --- /dev/null +++ b/tfjm/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for tfjm project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') + +application = get_asgi_application() diff --git a/tfjm/inputs.py b/tfjm/inputs.py new file mode 100644 index 0000000..67838fc --- /dev/null +++ b/tfjm/inputs.py @@ -0,0 +1,322 @@ +from json import dumps as json_dumps + +from django.forms.widgets import DateTimeBaseInput, NumberInput, TextInput, Widget + + +class AmountInput(NumberInput): + """ + This input type lets the user type amounts in euros, but forms receive data in cents + """ + template_name = "amount_input.html" + + def format_value(self, value): + return None if value is None or value == "" else "{:.02f}".format(int(value) / 100, ) + + def value_from_datadict(self, data, files, name): + val = super().value_from_datadict(data, files, name) + return str(int(100 * float(val))) if val else val + + +class Autocomplete(TextInput): + template_name = "autocomplete_model.html" + + def __init__(self, model, attrs=None): + super().__init__(attrs) + + self.model = model + self.model_pk = None + + class Media: + """JS/CSS resources needed to render the date-picker calendar.""" + + js = ('js/autocomplete_model.js', ) + + def format_value(self, value): + if value: + self.attrs["model_pk"] = int(value) + return str(self.model.objects.get(pk=int(value))) + return "" + + +class ColorWidget(Widget): + """ + Pulled from django-colorfield. + Select a color. + """ + template_name = 'colorfield/color.html' + + class Media: + js = [ + 'colorfield/jscolor/jscolor.min.js', + 'colorfield/colorfield.js', + ] + + def format_value(self, value): + if value is None: + value = 0xFFFFFF + return "#{:06X}".format(value) + + def value_from_datadict(self, data, files, name): + val = super().value_from_datadict(data, files, name) + return int(val[1:], 16) + + +""" +The remaining of this file comes from the project `django-bootstrap-datepicker-plus` available on Github: +https://github.com/monim67/django-bootstrap-datepicker-plus +This is distributed under Apache License 2.0. + +This adds datetime pickers with bootstrap. +""" + +"""Contains Base Date-Picker input class for widgets of this package.""" + + +class DatePickerDictionary: + """Keeps track of all date-picker input classes.""" + + _i = 0 + items = dict() + + @classmethod + def generate_id(cls): + """Return a unique ID for each date-picker input class.""" + cls._i += 1 + return 'dp_%s' % cls._i + + +class BasePickerInput(DateTimeBaseInput): + """Base Date-Picker input class for widgets of this package.""" + + template_name = 'bootstrap_datepicker_plus/date_picker.html' + picker_type = 'DATE' + format = '%Y-%m-%d' + config = {} + _default_config = { + 'id': None, + 'picker_type': None, + 'linked_to': None, + 'options': {} # final merged options + } + options = {} # options extended by user + options_param = {} # options passed as parameter + _default_options = { + 'showClose': True, + 'showClear': True, + 'showTodayButton': True, + "locale": "fr", + } + + # source: https://github.com/tutorcruncher/django-bootstrap3-datetimepicker + # file: /blob/31fbb09/bootstrap3_datetime/widgets.py#L33 + format_map = ( + ('DDD', r'%j'), + ('DD', r'%d'), + ('MMMM', r'%B'), + ('MMM', r'%b'), + ('MM', r'%m'), + ('YYYY', r'%Y'), + ('YY', r'%y'), + ('HH', r'%H'), + ('hh', r'%I'), + ('mm', r'%M'), + ('ss', r'%S'), + ('a', r'%p'), + ('ZZ', r'%z'), + ) + + class Media: + """JS/CSS resources needed to render the date-picker calendar.""" + + js = ( + 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/' + 'moment-with-locales.min.js', + 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/' + '4.17.47/js/bootstrap-datetimepicker.min.js', + 'bootstrap_datepicker_plus/js/datepicker-widget.js' + ) + css = {'all': ( + 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/' + '4.17.47/css/bootstrap-datetimepicker.css', + 'bootstrap_datepicker_plus/css/datepicker-widget.css' + ), } + + @classmethod + def format_py2js(cls, datetime_format): + """Convert python datetime format to moment datetime format.""" + for js_format, py_format in cls.format_map: + datetime_format = datetime_format.replace(py_format, js_format) + return datetime_format + + @classmethod + def format_js2py(cls, datetime_format): + """Convert moment datetime format to python datetime format.""" + for js_format, py_format in cls.format_map: + datetime_format = datetime_format.replace(js_format, py_format) + return datetime_format + + def __init__(self, attrs=None, format=None, options=None): + """Initialize the Date-picker widget.""" + self.format_param = format + self.options_param = options if options else {} + self.config = self._default_config.copy() + self.config['id'] = DatePickerDictionary.generate_id() + self.config['picker_type'] = self.picker_type + self.config['options'] = self._calculate_options() + attrs = attrs if attrs else {} + if 'class' not in attrs: + attrs['class'] = 'form-control' + super().__init__(attrs, self._calculate_format()) + + def _calculate_options(self): + """Calculate and Return the options.""" + _options = self._default_options.copy() + _options.update(self.options) + if self.options_param: + _options.update(self.options_param) + return _options + + def _calculate_format(self): + """Calculate and Return the datetime format.""" + _format = self.format_param if self.format_param else self.format + if self.config['options'].get('format'): + _format = self.format_js2py(self.config['options'].get('format')) + else: + self.config['options']['format'] = self.format_py2js(_format) + return _format + + def get_context(self, name, value, attrs): + """Return widget context dictionary.""" + context = super().get_context( + name, value, attrs) + context['widget']['attrs']['dp_config'] = json_dumps(self.config) + return context + + def start_of(self, event_id): + """ + Set Date-Picker as the start-date of a date-range. + + Args: + - event_id (string): User-defined unique id for linking two fields + """ + DatePickerDictionary.items[str(event_id)] = self + return self + + def end_of(self, event_id, import_options=True): + """ + Set Date-Picker as the end-date of a date-range. + + Args: + - event_id (string): User-defined unique id for linking two fields + - import_options (bool): inherit options from start-date input, + default: TRUE + """ + event_id = str(event_id) + if event_id in DatePickerDictionary.items: + linked_picker = DatePickerDictionary.items[event_id] + self.config['linked_to'] = linked_picker.config['id'] + if import_options: + backup_moment_format = self.config['options']['format'] + self.config['options'].update(linked_picker.config['options']) + self.config['options'].update(self.options_param) + if self.format_param or 'format' in self.options_param: + self.config['options']['format'] = backup_moment_format + else: + self.format = linked_picker.format + # Setting useCurrent is necessary, see following issue + # https://github.com/Eonasdan/bootstrap-datetimepicker/issues/1075 + self.config['options']['useCurrent'] = False + self._link_to(linked_picker) + else: + raise KeyError( + 'start-date not specified for event_id "%s"' % event_id) + return self + + def _link_to(self, linked_picker): + """ + Executed when two date-inputs are linked together. + + This method for sub-classes to override to customize the linking. + """ + pass + + +class DatePickerInput(BasePickerInput): + """ + Widget to display a Date-Picker Calendar on a DateField property. + + Args: + - attrs (dict): HTML attributes of rendered HTML input + - format (string): Python DateTime format eg. "%Y-%m-%d" + - options (dict): Options to customize the widget, see README + """ + + picker_type = 'DATE' + format = '%Y-%m-%d' + format_key = 'DATE_INPUT_FORMATS' + + +class TimePickerInput(BasePickerInput): + """ + Widget to display a Time-Picker Calendar on a TimeField property. + + Args: + - attrs (dict): HTML attributes of rendered HTML input + - format (string): Python DateTime format eg. "%Y-%m-%d" + - options (dict): Options to customize the widget, see README + """ + + picker_type = 'TIME' + format = '%H:%M' + format_key = 'TIME_INPUT_FORMATS' + template_name = 'bootstrap_datepicker_plus/time_picker.html' + + +class DateTimePickerInput(BasePickerInput): + """ + Widget to display a DateTime-Picker Calendar on a DateTimeField property. + + Args: + - attrs (dict): HTML attributes of rendered HTML input + - format (string): Python DateTime format eg. "%Y-%m-%d" + - options (dict): Options to customize the widget, see README + """ + + picker_type = 'DATETIME' + format = '%Y-%m-%d %H:%M' + format_key = 'DATETIME_INPUT_FORMATS' + + +class MonthPickerInput(BasePickerInput): + """ + Widget to display a Month-Picker Calendar on a DateField property. + + Args: + - attrs (dict): HTML attributes of rendered HTML input + - format (string): Python DateTime format eg. "%Y-%m-%d" + - options (dict): Options to customize the widget, see README + """ + + picker_type = 'MONTH' + format = '01/%m/%Y' + format_key = 'DATE_INPUT_FORMATS' + + +class YearPickerInput(BasePickerInput): + """ + Widget to display a Year-Picker Calendar on a DateField property. + + Args: + - attrs (dict): HTML attributes of rendered HTML input + - format (string): Python DateTime format eg. "%Y-%m-%d" + - options (dict): Options to customize the widget, see README + """ + + picker_type = 'YEAR' + format = '01/01/%Y' + format_key = 'DATE_INPUT_FORMATS' + + def _link_to(self, linked_picker): + """Customize the options when linked with other date-time input""" + yformat = self.config['options']['format'].replace('-01-01', '-12-31') + self.config['options']['format'] = yformat diff --git a/tfjm/middlewares.py b/tfjm/middlewares.py new file mode 100644 index 0000000..c5b8987 --- /dev/null +++ b/tfjm/middlewares.py @@ -0,0 +1,118 @@ +from django.conf import settings +from django.contrib.auth.models import AnonymousUser + +from threading import local + +from django.contrib.sessions.backends.db import SessionStore + +from member.models import TFJMUser +from tournament.models import Pool + +USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') +SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') +IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') + +_thread_locals = local() + + +def _set_current_user_and_ip(user=None, session=None, ip=None): + setattr(_thread_locals, USER_ATTR_NAME, user) + setattr(_thread_locals, SESSION_ATTR_NAME, session) + setattr(_thread_locals, IP_ATTR_NAME, ip) + + +def get_current_user() -> TFJMUser: + return getattr(_thread_locals, USER_ATTR_NAME, None) + + +def get_current_session() -> SessionStore: + return getattr(_thread_locals, SESSION_ATTR_NAME, None) + + +def get_current_ip() -> str: + return getattr(_thread_locals, IP_ATTR_NAME, None) + + +def get_current_authenticated_user(): + current_user = get_current_user() + if isinstance(current_user, AnonymousUser): + return None + return current_user + + +class SessionMiddleware(object): + """ + This middleware get the current user with his or her IP address on each request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if "_fake_user_id" in request.session: + request.user = TFJMUser.objects.get(pk=request.session["_fake_user_id"]) + + user = request.user + if 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR') + else: + ip = request.META.get('REMOTE_ADDR') + + _set_current_user_and_ip(user, request.session, ip) + response = self.get_response(request) + _set_current_user_and_ip(None, None, None) + + return response + + +class ExtraAccessMiddleware(object): + """ + This middleware allows some non authenticated people to access to pool data. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if "extra_access_token" in request.GET: + request.session["extra_access_token"] = request.GET["extra_access_token"] + if request.user.is_authenticated: + pool = Pool.objects.filter(extra_access_token=request.GET["extra_access_token"]) + if pool.exists(): + pool = pool.get() + pool.juries.add(request.user) + pool.save() + else: + request.session.setdefault("extra_access_token", "") + return self.get_response(request) + + +class TurbolinksMiddleware(object): + """ + Send the `Turbolinks-Location` header in response to a visit that was redirected, + and Turbolinks will replace the browser's topmost history entry. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + is_turbolinks = request.META.get('HTTP_TURBOLINKS_REFERRER') + is_response_redirect = response.has_header('Location') + + if is_turbolinks: + if is_response_redirect: + location = response['Location'] + prev_location = request.session.pop('_turbolinks_redirect_to', None) + if prev_location is not None: + # relative subsequent redirect + if location.startswith('.'): + location = prev_location.split('?')[0] + location + request.session['_turbolinks_redirect_to'] = location + else: + if request.session.get('_turbolinks_redirect_to'): + location = request.session.pop('_turbolinks_redirect_to') + response['Turbolinks-Location'] = location + return response diff --git a/tfjm/settings.py b/tfjm/settings.py new file mode 100644 index 0000000..af3f842 --- /dev/null +++ b/tfjm/settings.py @@ -0,0 +1,206 @@ +""" +Django settings for tfjm project. + +Generated by 'django-admin startproject' using Django 3.0.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os +import sys + +from django.utils.translation import gettext_lazy as _ + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +APPS_DIR = os.path.realpath(os.path.join(BASE_DIR, "apps")) +sys.path.append(APPS_DIR) + +ADMINS = [("Yohann D'ANELLO", "yohann.danello@animath.fr")] + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '6$wl1=ehfoiymin3m3i-wyx5d3t=1h7g4(j2izn*my)*yiq#he' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +SITE_ID = 1 + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.forms', + + 'django_extensions', + 'polymorphic', + 'crispy_forms', + 'django_tables2', + 'rest_framework', + 'rest_framework.authtoken', + + 'member', + 'tournament', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.contrib.sites.middleware.CurrentSiteMiddleware', + 'tfjm.middlewares.SessionMiddleware', + 'tfjm.middlewares.ExtraAccessMiddleware', + 'tfjm.middlewares.TurbolinksMiddleware', +] + +ROOT_URLCONF = 'tfjm.urls' + +LOGIN_REDIRECT_URL = "index" + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' + +WSGI_APPLICATION = 'tfjm.wsgi.application' + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTH_USER_MODEL = 'member.TFJMUser' + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', +] + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAdminUser' + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 50, +} + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'en' + +LANGUAGES = [ + ('en', _('English')), + ('fr', _('French')), +] + +TIME_ZONE = 'Europe/Paris' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")] + +FIXTURE_DIRS = [os.path.join(BASE_DIR, "tfjm/fixtures")] + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "static"), +] + +MEDIA_URL = '/media/' + +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +CRISPY_TEMPLATE_PACK = 'bootstrap4' + +DJANGO_TABLES2_TEMPLATE = 'django_tables2/bootstrap4.html' + +_db_type = os.getenv('DJANGO_DB_TYPE', 'sqlite').lower() + +if _db_type == 'mysql' or _db_type.startswith('postgres') or _db_type == 'psql': + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql' if _db_type == 'mysql' else 'django.db.backends.postgresql_psycopg2', + 'NAME': os.environ.get('DJANGO_DB_NAME', 'tfjm'), + 'USER': os.environ.get('DJANGO_DB_USER', 'tfjm'), + 'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'), + 'HOST': os.environ.get('DJANGO_DB_HOST', 'localhost'), + 'PORT': os.environ.get('DJANGO_DB_PORT', ''), # Use default port + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, os.getenv('DJANGO_DB_HOST', 'db.sqlite3')), + } + } + +if os.getenv("TFJM_STAGE", "dev") == "prod": + from .settings_prod import * +else: + from .settings_dev import * diff --git a/tfjm/settings_dev.py b/tfjm/settings_dev.py new file mode 100644 index 0000000..bf6e856 --- /dev/null +++ b/tfjm/settings_dev.py @@ -0,0 +1,4 @@ +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/tfjm/settings_prod.py b/tfjm/settings_prod.py new file mode 100644 index 0000000..fde3682 --- /dev/null +++ b/tfjm/settings_prod.py @@ -0,0 +1,29 @@ +import os + +# Break it, fix it! +DEBUG = False + +# Mandatory ! +ALLOWED_HOSTS = ['inscription.tfjm.org'] + +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS') + +# Emails +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_SSL = True +EMAIL_HOST = os.getenv("SMTP_HOST") +EMAIL_PORT = os.getenv("SMTP_PORT") +EMAIL_HOST_USER = os.getenv("SMTP_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("SMTP_HOST_PASSWORD") + +DEFAULT_FROM_EMAIL = os.getenv('FROM_EMAIL', 'Contact TFJM² ') +SERVER_EMAIL = os.getenv('SERVER_EMAIL', 'contact@tfjm.org') + +# Security settings +SECURE_CONTENT_TYPE_NOSNIFF = False +SECURE_BROWSER_XSS_FILTER = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False +CSRF_COOKIE_HTTPONLY = False +X_FRAME_OPTIONS = 'DENY' +SESSION_COOKIE_AGE = 60 * 60 * 3 diff --git a/tfjm/urls.py b/tfjm/urls.py new file mode 100644 index 0000000..e9903a6 --- /dev/null +++ b/tfjm/urls.py @@ -0,0 +1,56 @@ +"""tfjm URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +import re + +from django.conf import settings +from django.contrib import admin +from django.contrib.staticfiles.views import serve +from django.urls import path, include, re_path +from django.views.defaults import bad_request, permission_denied, page_not_found, server_error +from django.views.generic import TemplateView, RedirectView + +from member.views import DocumentView + +urlpatterns = [ + path('', TemplateView.as_view(template_name="index.html"), name="index"), + path('i18n/', include('django.conf.urls.i18n')), + path('admin/doc/', include('django.contrib.admindocs.urls')), + path('admin/', admin.site.urls, name="admin"), + path('accounts/', include('django.contrib.auth.urls')), + + path('member/', include('member.urls')), + path('tournament/', include('tournament.urls')), + + path("media//", DocumentView.as_view(), name="document"), + + path('api/', include('api.urls')), + + re_path(r'^{prefix}(?P.*)$'.format(prefix=re.escape(settings.STATIC_URL.lstrip('/'))), serve), + + # Supporting old paths + path('inscription/', RedirectView.as_view(pattern_name="member:signup")), + path('connexion/', RedirectView.as_view(pattern_name="login")), + path('tournois/', RedirectView.as_view(pattern_name="tournament:list")), + path('mon-compte/', RedirectView.as_view(pattern_name="member:my_account")), + path('mon-equipe/', RedirectView.as_view(pattern_name="member:my_team")), + path('solutions/', RedirectView.as_view(pattern_name="tournament:solutions")), + path('syntheses/', RedirectView.as_view(pattern_name="tournament:syntheses")), +] + +handler400 = bad_request +handler403 = permission_denied +handler404 = page_not_found +handler500 = server_error diff --git a/tfjm/wsgi.py b/tfjm/wsgi.py new file mode 100644 index 0000000..7fd654c --- /dev/null +++ b/tfjm/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for tfjm project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') + +application = get_wsgi_application()