diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5d07a0c5d4..ae61c31efa 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -41,11 +41,11 @@ ainsi que dans les espaces publics quand un individu représente le projet ou sa communauté. Les instances de comportement abusif, harcelant ou autrement inacceptable -peuvent être signalés en contactant un responsable de projet à -zestedesavoir at gmail.com. Toutes les plaintes seront examinées et étudiées +peuvent être signalées en contactant un responsable de projet à +`zds-tech@googlegroups.com`. Toutes les plaintes seront examinées et étudiées et se traduiront par une réponse jugée nécessaire et appropriée aux circonstances. Les mainteneurs s'obligent à garder confidentielles les informations de la personne qui remonte un incident. -Ce Code de Conduite est adaptée du [Contributor Covenant](http://contributor-covenant.org), -version 1.3.0, [disponible ici](http://contributor-covenant.org/version/1/3/0/fr). \ No newline at end of file +Ce Code de Conduite est adapté du [Contributor Covenant](http://contributor-covenant.org), +version 1.3.0, [disponible ici](http://contributor-covenant.org/version/1/3/0/fr). diff --git a/Makefile b/Makefile index 7b4b79aba9..d1e6fc9aa0 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,24 @@ +.PHONY: fixtures + all: help # install ## linux install-debian: - sudo apt-get install git python-dev python-setuptools libxml2-dev python-lxml libxslt-dev libz-dev python-sqlparse libjpeg62-turbo libjpeg62-turbo-dev libfreetype6 libfreetype6-dev libffi-dev python-pip python-tox + sudo apt-get install git python-dev python-setuptools libxml2-dev python-lxml libxslt-dev libz-dev python-sqlparse libjpeg62-turbo libjpeg62-turbo-dev libfreetype6 libfreetype6-dev libffi-dev python-pip python-tox build-essential install-ubuntu: - sudo apt-get install git python-dev python-setuptools libxml2-dev python-lxml libxslt1-dev libz-dev python-sqlparse libjpeg8 libjpeg8-dev libfreetype6 libfreetype6-dev libffi-dev python-pip python-tox + sudo apt-get install git python-dev python-setuptools libxml2-dev python-lxml libxslt1-dev libz-dev python-sqlparse libjpeg8 libjpeg8-dev libfreetype6 libfreetype6-dev libffi-dev python-pip python-tox build-essential install-fedora: - sudo dnf install git python-devel python-setuptools libxml2-devel python-lxml libxslt-devel zlib-devel python-sqlparse libjpeg-turbo-devel libjpeg-turbo-devel freetype freetype-devel libffi-devel python-pip python-tox + sudo dnf install git python-devel python-setuptools libxml2-devel python-lxml libxslt-devel zlib-devel python-sqlparse libjpeg-turbo-devel libjpeg-turbo-devel freetype freetype-devel libffi-devel python-pip python-tox gcc redhat-rpm-config + +install-archlinux: + sudo pacman -Sy git python2 python2-setuptools python2-pip libxml2 python2-lxml libxslt zlib python2-sqlparse libffi libjpeg-turbo freetype2 python2-tox base-devel install-osx: - brew install virtualenv_select py27-virtualenv py27-virtualenvwrapper py27-tox node + brew install gettext cairo --without-x11 py2cairo node && \ + pip install virtualenv virtualenvwrapper # dev back ## django @@ -75,12 +81,13 @@ doc: make html fixtures: - python manage.py loaddata fixtures/*.yaml. + python manage.py loaddata fixtures/*.yaml help: @echo "Please use \`make ' where is one of" @echo " build-front to build frontend code" @echo " doc to generate the html documentation" + @echo " fixtures to load every fixtures" @echo " generate-pdf to regenerate all PDFs" @echo " help to get this help" @echo " install-back to install backend dependencies" @@ -88,6 +95,7 @@ help: @echo " install-debian to install debian dependencies" @echo " install-ubuntu to install ubuntu dependencies" @echo " install-fedora to install fedora dependencies" + @echo " install-archlinux to install archlinux dependencies" @echo " install-osx to install os x dependencies" @echo " lint-back to lint backend code (flake8)" @echo " lint-front to lint frontend code (jshint)" diff --git a/assets/js/ajax-actions.js b/assets/js/ajax-actions.js index 9e1516a54c..b3aca77ef4 100644 --- a/assets/js/ajax-actions.js +++ b/assets/js/ajax-actions.js @@ -236,7 +236,7 @@ $(data).insertAfter($form); /* global MathJax */ - if ($(data).find("$").length > 0) + if (data.indexOf("$") > 0) MathJax.Hub.Queue(["Typeset", MathJax.Hub]); } }); diff --git a/assets/js/jquery-tabbable.js b/assets/js/jquery-tabbable.js index 85d50af069..bc0f0d87a3 100644 --- a/assets/js/jquery-tabbable.js +++ b/assets/js/jquery-tabbable.js @@ -43,7 +43,7 @@ if(!element.href || !mapName || map.nodeName.toLowerCase() !== "map"){ return false; } - img = $("img[usemap=#" + mapName + "]")[0]; + img = $("img[usemap='#" + mapName + "']")[0]; return !!img && visible(img); } return ( /input|select|textarea|button|object/.test(nodeName) ? diff --git a/assets/js/modal.js b/assets/js/modal.js index efe3087629..4546ff3bf5 100644 --- a/assets/js/modal.js +++ b/assets/js/modal.js @@ -191,7 +191,7 @@ */ function buildModals($elems){ $elems.each(function(){ - var $link = $("[href=#"+$(this).attr("id")+"]:first"); + var $link = $("[href='#"+$(this).attr("id")+"']:first"); var linkIco = ""; if($link.hasClass("ico-after")) { diff --git a/assets/scss/base/_forms.scss b/assets/scss/base/_forms.scss index c4bcddd1b8..9eafaa8560 100644 --- a/assets/scss/base/_forms.scss +++ b/assets/scss/base/_forms.scss @@ -314,6 +314,9 @@ .checkbox-new-content { padding: 0; } + #div_id_helps .checkbox { + padding: 0; + } } @media only screen and #{$media-wide} { diff --git a/assets/scss/components/_pagination.scss b/assets/scss/components/_pagination.scss index 6e80191022..1eefe13aed 100644 --- a/assets/scss/components/_pagination.scss +++ b/assets/scss/components/_pagination.scss @@ -5,8 +5,9 @@ border-top: 1px solid #d2d5d6; // @TODO: Color border-bottom: 1px solid #d2d5d6; background: #FBFBFB; - height: 40px; margin-bottom: 20px !important; + display: flex; + flex-wrap: wrap; li { float: left; @@ -56,7 +57,7 @@ } } &.next { - float: right; + margin-left: auto; .ico-after { padding-right: 30px; diff --git a/assets/scss/components/_topic-message.scss b/assets/scss/components/_topic-message.scss index d919f1285c..07846bab1c 100644 --- a/assets/scss/components/_topic-message.scss +++ b/assets/scss/components/_topic-message.scss @@ -664,7 +664,7 @@ form.topic-message { } } - form.forum-message .message { + form .message { padding-top: 0 !important; } } diff --git a/doc/source/back-end-code/utils.rst b/doc/source/back-end-code/utils.rst index 74598897cd..846765fc32 100644 --- a/doc/source/back-end-code/utils.rst +++ b/doc/source/back-end-code/utils.rst @@ -45,6 +45,13 @@ La doc de Django explique le principe des *context_processors* comme suit : .. automodule:: zds.utils.context_processor :members: +Utilitaires pour formulaires (``forms.py``) +=========================================== + +.. automodule:: zds.utils.forms + :members: + + Autres (``misc.py``) ==================== diff --git a/doc/source/install/backend-linux-install.rst b/doc/source/install/backend-linux-install.rst index 8204b76d73..03c9fbb7aa 100644 --- a/doc/source/install/backend-linux-install.rst +++ b/doc/source/install/backend-linux-install.rst @@ -28,6 +28,9 @@ ZdS a besoin des dépendances suivantes, installables manuellement ou à l'aide - python-sqlparse - libffi : ``apt-get install libffi-dev`` - libjpeg62-turbo libjpeg62-turbo-dev libfreetype6 libfreetype6-dev : ``apt-get install libjpeg62-turbo libjpeg62-turbo-dev libfreetype6 libfreetype6-dev`` (peut être appelée libjpeg8 et libjpeg8-dev sur certains OS comme Ubuntu) +- gcc : ``apt-get install build-essential`` + +**NB** : pour les utilisateurs d'Archlinux, les outils python doivent être ceux de python 2, généralement sous la forme ``python2-smth`` Ou à l'aide du Makefile (``sudo`` sera appelé automatiquement, ne l'ajoutez jamais si on ne le précise pas) : @@ -49,6 +52,12 @@ Pour Fedora. make install-fedora +Pour Archlinux. + +.. sourcecode:: bash + + make install-archlinux + Installation et configuration de `virtualenv` ============================================= diff --git a/doc/source/install/backend-os-x-install.rst b/doc/source/install/backend-os-x-install.rst index 377f847bbc..2223ae08d7 100644 --- a/doc/source/install/backend-os-x-install.rst +++ b/doc/source/install/backend-os-x-install.rst @@ -5,68 +5,79 @@ Installation du backend sous macOS Pour installer une version locale de ZdS sur macOS, veuillez suivre les instructions suivantes. Si une commande ne passe pas, essayez de savoir pourquoi avant de continuer. -Avant de vous lancez dans l'installation de l'environnement de zds, il faut quelques pré-requis : +Pré-requis +========== -- Installer `XCode `_ pour pouvoir exécuter des commandes (g)cc. -- Installer `Homebrew `_ pour récupérer certains paquets utiles pour l'installation des dépendances de ce projet. -- Installer python 2.7 -- Installer pip -- Installer git -- Installer `gettext `_ -- Installer GeoIP (``brew install geoip``) +- Installer XCode : -Une fois les pré-requis terminés, vous pouvez vous lancer dans l'installaton de l'environnement de zds. +.. sourcecode:: bash -Installation de virtualenv -========================== + xcode-select --install + +- Installer `Homebrew `_. +- Installer un nouveau Python par Homebrew : .. sourcecode:: bash - make install-osx + brew install python --framework + # Il se peut que votre système n'utilise pas la nouvelle version de Python. Si c'est le cas, lancez la commande suivante. + export PATH=/usr/local/bin:/usr/local/sbin:${PATH} - mkdir ~/.virtualenvs - echo "export WORKON_HOME=$HOME/.virtualenvs" >> ~/.bash_profile && export WORKON_HOME=$HOME/.virtualenvs - echo "source /usr/local/bin/virtualenvwrapper.sh" >> ~/.bash_profile && source /usr/local/bin/virtualenvwrapper.sh +- Si vous avez décidé de ne pas installer un nouveau python et que vous utilisez celui de base du système, installez pip : +.. sourcecode:: bash -Création de votre environnement -=============================== + wget https://bootstrap.pypa.io/get-pip.py + python get-pip.py + +- Installer toutes les dépendances systèmes nécessaires : .. sourcecode:: bash - mkvirtualenv zdsenv + make install-osx +Une fois les pré-requis terminés, vous pouvez vous lancer dans l'installaton de l'environnement de zds. -Récupération de cairo (svg) +Configuration de virtualenv =========================== .. sourcecode:: bash - brew install cairo --without-x11 - brew install py2cairo # py3cairo quand ZdS sera en python 3 + mkdir ~/.virtualenvs + echo "export WORKON_HOME=$HOME/.virtualenvs" >> ~/.bash_profile + echo "source /usr/local/bin/virtualenvwrapper.sh" >> ~/.bash_profile + source ~/.bash_profile +Usage de virtualenv +=================== -Installation des outils front-end -================================= +Création d'un virtualenv : -Il vous faut installer les outils du front-end. Pour cela, rendez-vous sur `la documentation dédiée `_. +.. sourcecode:: bash -Sur macOS, ``brew install node`` suffit pour installer Node.js + mkvirtualenv zdsenv -Installation de toutes les dépendances -====================================== +Lancer un virtualenv : .. sourcecode:: bash - make install-back - make install-front + workon zdsenv + +Quitter un virtualenv : + +.. sourcecode:: bash + deactivate -Pour relancer votre environnement : ``source ~/.virtualenvs/zdsenv/bin/activate``. -Si vous avez installé virtualenvwrapper, vous pouvez utiliser le raccourcis ``workon zdsenv``. +Installation des des dépendances front et back +============================================== -Pour sortir de votre environnement : ``deactivate``. +.. sourcecode:: bash + + make install-front + make build-front + make install-back Lancer ZdS ========== diff --git a/doc/source/install/configs/nginx/sites-available/zestedesavoir b/doc/source/install/configs/nginx/sites-available/zestedesavoir index b51f6639b4..fa1781d500 100644 --- a/doc/source/install/configs/nginx/sites-available/zestedesavoir +++ b/doc/source/install/configs/nginx/sites-available/zestedesavoir @@ -12,8 +12,8 @@ map $http_host $robots_tag_header { server { listen 80 default_server; listen [::]:80 default_server; - listen 443 ssl spdy default_server; - listen [::]:443 ssl spdy default_server; + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; server_name zestedesavoir.com; diff --git a/doc/source/install/configs/settings_prod.py b/doc/source/install/configs/settings_prod.py index b83b930406..9b21b7d11f 100644 --- a/doc/source/install/configs/settings_prod.py +++ b/doc/source/install/configs/settings_prod.py @@ -148,7 +148,7 @@ PANDOC_PDF_PARAM = ("--latex-engine=xelatex " "--template={} -s -S -N " "--toc -V documentclass=scrbook -V lang=francais " - "-V mainfont=Merriweather -V monofont=\"Andale Mono\" " + "-V mainfont=Merriweather -V monofont=\"SourceCodePro-Regular\" " "-V fontsize=12pt -V geometry:margin=1in ".format('/opt/zds/zds-site/assets/tex/template.tex')) # Sentry (+ raven, the Python Client) diff --git a/doc/source/install/deploy-in-production.rst b/doc/source/install/deploy-in-production.rst index 001b0e3559..ef17a3d5de 100644 --- a/doc/source/install/deploy-in-production.rst +++ b/doc/source/install/deploy-in-production.rst @@ -105,7 +105,11 @@ Gunicorn Nginx ~~~~~ -Installer nginx. La configuration nginx de Zeste de Savoir est séparée en plusieurs fichiers, en plus des quelques fichiers de configuration par défaut de nginx: +Installer nginx. + +Une version récente de nginx est nécessaire pour utiliser HTTP/2. Si la version installée est inférieure à la version 1.9.5 il faut la mettre à jour avec celle du dépot backports : ``sudo apt-get -t jessie-backports install nginx openssl``. Toutefois, HTTP/2 n'est pas nécessaire au bon fonctionnement de Zeste de Savoir, pensez juste à adapter ``sites-available/zestedesavoir``. + +La configuration nginx de Zeste de Savoir est séparée en plusieurs fichiers, en plus des quelques fichiers de configuration par défaut de nginx: .. code:: text diff --git a/doc/source/utils/fixture_loaders.rst b/doc/source/utils/fixture_loaders.rst index 83d4e041a5..d9817f0718 100644 --- a/doc/source/utils/fixture_loaders.rst +++ b/doc/source/utils/fixture_loaders.rst @@ -29,7 +29,7 @@ Nous possédons un ensemble de données sérialisées dans le dossier fixtures: - External/external: le compte pour accueillir les cours externes des auteurs ne voulant pas devenir membre ou quittant le site - decal/decal: le compte qui possède un identifiant ``Profile`` différent de l'identifiant ``user`` pour permettre de tester des cas ou ces id sont différents -De ce fait, le moyen le plus simple de charger l'ensemble des données de base est ``python manage.py loaddata fixtures/*.yaml``. +De ce fait, le moyen le plus simple de charger l'ensemble des données de base est la commande ``make fixtures``. Les données complexes voire les scénarios ----------------------------------------- diff --git a/export-assets/fonts/Merriweather/LICENSE b/export-assets/fonts/Merriweather/LICENSE new file mode 100644 index 0000000000..29d07d9f2c --- /dev/null +++ b/export-assets/fonts/Merriweather/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2010-2013, Sorkin Type Co (www.sorkintype.com) with Reserved Font Name 'Merriweather' + +Merriweather is a trademark of Sorkin Type Co. +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/export-assets/fonts/Merriweather/Merriweather-Black.ttf b/export-assets/fonts/Merriweather/Merriweather-Black.ttf new file mode 100644 index 0000000000..51864574e4 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-Black.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-Bold.ttf b/export-assets/fonts/Merriweather/Merriweather-Bold.ttf new file mode 100644 index 0000000000..2340777c83 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-Bold.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-BoldItalic.ttf b/export-assets/fonts/Merriweather/Merriweather-BoldItalic.ttf new file mode 100644 index 0000000000..f67d7e588a Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-BoldItalic.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-HeavyItalic.ttf b/export-assets/fonts/Merriweather/Merriweather-HeavyItalic.ttf new file mode 100644 index 0000000000..f94d0ccca5 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-HeavyItalic.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-Italic.ttf b/export-assets/fonts/Merriweather/Merriweather-Italic.ttf new file mode 100644 index 0000000000..5833586523 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-Italic.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-Light.ttf b/export-assets/fonts/Merriweather/Merriweather-Light.ttf new file mode 100644 index 0000000000..ee7adbd44f Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-Light.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-LightItalic.ttf b/export-assets/fonts/Merriweather/Merriweather-LightItalic.ttf new file mode 100644 index 0000000000..3373e61869 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-LightItalic.ttf differ diff --git a/export-assets/fonts/Merriweather/Merriweather-Regular.ttf b/export-assets/fonts/Merriweather/Merriweather-Regular.ttf new file mode 100644 index 0000000000..28a80e4873 Binary files /dev/null and b/export-assets/fonts/Merriweather/Merriweather-Regular.ttf differ diff --git a/export-assets/fonts/SourceCodePro/LICENSE.txt b/export-assets/fonts/SourceCodePro/LICENSE.txt new file mode 100644 index 0000000000..d154618a7d --- /dev/null +++ b/export-assets/fonts/SourceCodePro/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/export-assets/fonts/SourceCodePro/SourceCodePro-Regular.ttf b/export-assets/fonts/SourceCodePro/SourceCodePro-Regular.ttf new file mode 100644 index 0000000000..85686d967d Binary files /dev/null and b/export-assets/fonts/SourceCodePro/SourceCodePro-Regular.ttf differ diff --git a/export-assets/pandoc/default.epub b/export-assets/pandoc/default.epub new file mode 100644 index 0000000000..1c042ff1e1 --- /dev/null +++ b/export-assets/pandoc/default.epub @@ -0,0 +1,59 @@ + + + + + + + + $pagetitle$ +$if(highlighting-css)$ + +$endif$ +$for(css)$ + +$endfor$ +$for(header-includes)$ + $header-includes$ +$endfor$ + + +$if(titlepage)$ +$for(title)$ +$if(title.text)$ +

$title.text$

+$else$ +

$title$

+$endif$ +$endfor$ +$if(subtitle)$ +

$subtitle$

+$endif$ +$for(author)$ +

$author$

+$endfor$ +$for(creator)$ +

$creator.text$

+$endfor$ +$if(publisher)$ +

$publisher$

+$endif$ +$if(date)$ +

$date$

+$endif$ +$if(rights)$ +
$rights$
+$endif$ +$else$ +$for(include-before)$ +$include-before$ +$endfor$ +$body$ +$for(include-after)$ +$include-after$ +$endfor$ +$endif$ + + + diff --git a/export-assets/pandoc/default.html b/export-assets/pandoc/default.html new file mode 100644 index 0000000000..8caea26c86 --- /dev/null +++ b/export-assets/pandoc/default.html @@ -0,0 +1,64 @@ + + + + + + +$for(author-meta)$ + +$endfor$ +$if(date-meta)$ + +$endif$ +$if(keywords)$ + +$endif$ + $if(title-prefix)$$title-prefix$ – $endif$$pagetitle$ + +$if(quotes)$ + +$endif$ +$if(highlighting-css)$ + +$endif$ +$for(css)$ + +$endfor$ +$if(math)$ + $math$ +$endif$ +$for(header-includes)$ + $header-includes$ +$endfor$ + + +$for(include-before)$ +$include-before$ +$endfor$ +$if(title)$ +
+

$title$

+$if(subtitle)$ +

$subtitle$

+$endif$ +$for(author)$ +

$author$

+$endfor$ +$if(date)$ +

$date$

+$endif$ +
+$endif$ +$if(toc)$ +
+$toc$ +
+$endif$ +$body$ +$for(include-after)$ +$include-after$ +$endfor$ + + diff --git a/export-assets/pandoc/pandoc.zip b/export-assets/pandoc/pandoc.zip new file mode 100644 index 0000000000..8c4505ca5e Binary files /dev/null and b/export-assets/pandoc/pandoc.zip differ diff --git a/fixtures/categories.yaml b/fixtures/categories.yaml index b061eb4ca1..c59977c892 100644 --- a/fixtures/categories.yaml +++ b/fixtures/categories.yaml @@ -1,172 +1,327 @@ -- fields: {description: Informatique, position: 0, slug: informatique, title: Informatique} +- fields: + description: Informatique + position: 0 + slug: informatique + title: Informatique model: utils.category pk: 1 -- fields: {description: Sciences de la nature, position: 1, slug: sciences-de-la-nature, - title: Sciences de la nature} +- fields: + description: Sciences de la nature + position: 1 + slug: sciences-de-la-nature + title: Sciences de la nature model: utils.category pk: 2 -- fields: {description: Sciences humaines et sociales, position: 2, slug: sciences-humaines-et-sociales, - title: Sciences humaines et sociales} +- fields: + description: Sciences humaines et sociales + position: 2 + slug: sciences-humaines-et-sociales + title: Sciences humaines et sociales model: utils.category pk: 3 -- fields: {description: Autres, position: 3, slug: autres, title: Autres} +- fields: + description: Autres + position: 3 + slug: autres + title: Autres model: utils.category pk: 4 -- fields: {image: '', slug: bureautique-et-redaction, subtitle: 'Excel, LaTeX, Powerpoint, - Word', title: "Bureautique et r\xE9daction"} +- fields: + image: '' + slug: bureautique-et-redaction + subtitle: 'Excel, LaTeX, Powerpoint, Word' + title: "Bureautique et r\xE9daction" model: utils.subcategory pk: 1 -- fields: {image: '', slug: materiel-et-electronique, subtitle: "Arduino, Disque - dur, DIY, Electronique*, M\xE9moires, Ordinateur", title: "Mat\xE9riel - et \xE9lectronique"} +- fields: + image: '' + slug: materiel-et-electronique + subtitle: "Arduino, Disque dur, DIY, Electronique*, M\xE9moires, Ordinateur" + title: "Mat\xE9riel et \xE9lectronique" model: utils.subcategory pk: 2 -- fields: {image: '', slug: programmation-et-algorithmique, subtitle: ".NET, Ada, - Algorithmique, C, C#, C++, Cobol, Fonctionnel, G\xE9nie logiciel, Haskell, - Java, Julia, Lisp, Ocaml, Orient\xE9 objet, Python, Ruby, Versioning", - title: Programmation et algorithmique} +- fields: + image: '' + slug: programmation-et-algorithmique + subtitle: ".NET, Ada, Algorithmique, C, C#, C++, Cobol, Fonctionnel, G\xE9nie logiciel, Haskell, Java, Julia, Lisp, Ocaml, Orient\xE9 objet, Python, Ruby, Versioning" + title: Programmation et algorithmique model: utils.subcategory pk: 3 -- fields: {image: '', slug: site-web, subtitle: "Accessibilit\xE9, Actionscript, - Angular JS, CakePHP, Django, HTML/CSS, Java EE, JavaScript, Nginx, Node.js, - Oxygen, PHP, Ruby On Rails, SEO/R\xE9ferencement*, Symfony, Websocket", - title: Site web} +- fields: + image: '' + slug: site-web + subtitle: "Accessibilit\xE9, Actionscript, Angular JS, CakePHP, Django, HTML/CSS, Java EE, JavaScript, Nginx, Node.js, Oxygen, PHP, Ruby On Rails, SEO/R\xE9ferencement*, Symfony, Websocket" + title: Site web model: utils.subcategory pk: 4 -- fields: {image: '', slug: systemes-dexploitation, subtitle: 'Android, GNU/Linux, - iOS, MAC OS, Windows, Windows Phone', title: "Syst\xE8mes d'exploitation"} +- fields: + image: '' + slug: systemes-dexploitation + subtitle: 'Android, GNU/Linux, iOS, MAC OS, Windows, Windows Phone' + title: "Syst\xE8mes d'exploitation" model: utils.subcategory pk: 5 -- fields: {image: '', slug: autres-informatique, subtitle: "API, Base de donn\xE9es, - FTP, Jeux vid\xE9os, MySQL, Oracle, Protocole, S\xE9curit\xE9, TCP/IP", - title: Autres (informatique)} +- fields: + image: '' + slug: autres-informatique + subtitle: "API, Base de donn\xE9es, FTP, Jeux vid\xE9os, MySQL, Oracle, Protocole, S\xE9curit\xE9, TCP/IP" + title: Autres (informatique) model: utils.subcategory pk: 6 -- fields: {image: '', slug: astronomie, subtitle: Astronomie, title: Astronomie} +- fields: + image: '' + slug: astronomie + subtitle: Astronomie + title: Astronomie model: utils.subcategory pk: 7 -- fields: {image: '', slug: geologie-et-geographie-physique, subtitle: "G\xE9ologie", - title: "G\xE9ologie et g\xE9ographie physique"} +- fields: + image: '' + slug: geologie-et-geographie-physique + subtitle: "G\xE9ologie" + title: "G\xE9ologie et g\xE9ographie physique" model: utils.subcategory pk: 8 -- fields: {image: '', slug: biologie, subtitle: Biologie, title: Biologie} +- fields: + image: '' + slug: biologie + subtitle: Biologie + title: Biologie model: utils.subcategory pk: 9 -- fields: {image: '', slug: physique, subtitle: Physique, title: Physique} +- fields: + image: '' + slug: physique + subtitle: Physique + title: Physique model: utils.subcategory pk: 10 -- fields: {image: '', slug: chimie, subtitle: Chimie, title: Chimie} +- fields: + image: '' + slug: chimie + subtitle: Chimie + title: Chimie model: utils.subcategory pk: 11 -- fields: {image: '', slug: mathematiques, subtitle: "Math\xE9matiques", title: "Math\xE9matiques"} +- fields: + image: '' + slug: mathematiques + subtitle: "Math\xE9matiques" + title: "Math\xE9matiques" model: utils.subcategory pk: 12 -- fields: {image: '', slug: autres-sciences-de-la-nature, subtitle: Autres sciences - de la nature, title: Autres (sciences de la nature)} +- fields: + image: '' + slug: autres-sciences-de-la-nature + subtitle: Autres science de la nature + title: Autres (sciences de la nature) model: utils.subcategory pk: 13 -- fields: {image: '', slug: droit, subtitle: Droit, title: Droit} +- fields: + image: '' + slug: droit + subtitle: Droit + title: Droit model: utils.subcategory pk: 14 -- fields: {image: '', slug: histoire, subtitle: Histoire, title: Histoire} +- fields: + image: '' + slug: histoire + subtitle: Histoire + title: Histoire model: utils.subcategory pk: 15 -- fields: {image: '', slug: langues, subtitle: Langues, title: Langues} +- fields: + image: '' + slug: langues + subtitle: Langues + title: Langues model: utils.subcategory pk: 16 -- fields: {image: '', slug: psychologie, subtitle: "Psychologie, P\xE9dagogie", - title: Psychologie} +- fields: + image: '' + slug: psychologie + subtitle: "Psychologie, P\xE9dagogie" + title: Psychologie model: utils.subcategory pk: 17 -- fields: {image: '', slug: economie, subtitle: "\xC9conomie", title: "\xC9conomie"} +- fields: + image: '' + slug: economie + subtitle: "\xC9conomie" + title: "\xC9conomie" model: utils.subcategory pk: 18 -- fields: {image: '', slug: autres-sciences-humaines-et-sociales, subtitle: "Autres - sciences humaines et sociales comme la g\xE9ographie", title: Autres (sciences - humaines et sociales)} +- fields: + image: '' + slug: autres-sciences-humaines-et-sociales + subtitle: "Autre sciences humaines et sociales comme la g\xE9ographie" + title: Autres (sciences humaines et sociales) model: utils.subcategory pk: 19 -- fields: {image: '', slug: arts-graphisme-et-multimedia, subtitle: 'Graphisme 2D, - Graphisme 3D, Musique, Son', title: "Arts, graphisme et multim\xE9dia"} +- fields: + image: '' + slug: arts-graphisme-et-multimedia + subtitle: 'Graphisme 2D, Graphisme 3D, Musique, Son' + title: "Arts, graphisme et multim\xE9dia" model: utils.subcategory pk: 20 -- fields: {image: '', slug: communication-et-management, subtitle: Monde du travail, - title: Communication et management} +- fields: + image: '' + slug: communication-et-management + subtitle: Monde du travail + title: Communication et management model: utils.subcategory pk: 21 -- fields: {image: '', slug: zeste-de-savoir, subtitle: Zeste de Savoir, title: Zeste - de Savoir} +- fields: + image: '' + slug: zeste-de-savoir + subtitle: Zeste de Savoir + title: Zeste de Savoir model: utils.subcategory pk: 22 -- fields: {image: '', slug: autres, subtitle: "Litt\xE9rature, Interview", title: Autres} +- fields: + image: '' + slug: autres + subtitle: "Litt\xE9rature, Interview" + title: Autres model: utils.subcategory pk: 23 -- fields: {category: 1, is_main: true, subcategory: 1} +- fields: + category: 1 + is_main: true + subcategory: 1 model: utils.categorysubcategory pk: 1 -- fields: {category: 1, is_main: true, subcategory: 2} +- fields: + category: 1 + is_main: true + subcategory: 2 model: utils.categorysubcategory pk: 2 -- fields: {category: 1, is_main: true, subcategory: 3} +- fields: + category: 1 + is_main: true + subcategory: 3 model: utils.categorysubcategory pk: 3 -- fields: {category: 1, is_main: true, subcategory: 4} +- fields: + category: 1 + is_main: true + subcategory: 4 model: utils.categorysubcategory pk: 4 -- fields: {category: 1, is_main: true, subcategory: 5} +- fields: + category: 1 + is_main: true + subcategory: 5 model: utils.categorysubcategory pk: 5 -- fields: {category: 1, is_main: true, subcategory: 6} +- fields: + category: 1 + is_main: true + subcategory: 6 model: utils.categorysubcategory pk: 6 -- fields: {category: 2, is_main: true, subcategory: 7} +- fields: + category: 2 + is_main: true + subcategory: 7 model: utils.categorysubcategory pk: 7 -- fields: {category: 2, is_main: true, subcategory: 8} +- fields: + category: 2 + is_main: true + subcategory: 8 model: utils.categorysubcategory pk: 8 -- fields: {category: 2, is_main: true, subcategory: 9} +- fields: + category: 2 + is_main: true + subcategory: 9 model: utils.categorysubcategory pk: 9 -- fields: {category: 2, is_main: true, subcategory: 10} +- fields: + category: 2 + is_main: true + subcategory: 10 model: utils.categorysubcategory pk: 10 -- fields: {category: 2, is_main: true, subcategory: 11} +- fields: + category: 2 + is_main: true + subcategory: 11 model: utils.categorysubcategory pk: 11 -- fields: {category: 2, is_main: true, subcategory: 12} +- fields: + category: 2 + is_main: true + subcategory: 12 model: utils.categorysubcategory pk: 12 -- fields: {category: 2, is_main: true, subcategory: 13} +- fields: + category: 2 + is_main: true + subcategory: 13 model: utils.categorysubcategory pk: 13 -- fields: {category: 3, is_main: true, subcategory: 14} +- fields: + category: 3 + is_main: true + subcategory: 14 model: utils.categorysubcategory pk: 14 -- fields: {category: 3, is_main: true, subcategory: 15} +- fields: + category: 3 + is_main: true + subcategory: 15 model: utils.categorysubcategory pk: 15 -- fields: {category: 3, is_main: true, subcategory: 16} +- fields: + category: 3 + is_main: true + subcategory: 16 model: utils.categorysubcategory pk: 16 -- fields: {category: 3, is_main: true, subcategory: 17} +- fields: + category: 3 + is_main: true + subcategory: 17 model: utils.categorysubcategory pk: 17 -- fields: {category: 3, is_main: true, subcategory: 18} +- fields: + category: 3 + is_main: true + subcategory: 18 model: utils.categorysubcategory pk: 18 -- fields: {category: 3, is_main: true, subcategory: 19} +- fields: + category: 3 + is_main: true + subcategory: 19 model: utils.categorysubcategory pk: 19 -- fields: {category: 4, is_main: true, subcategory: 20} +- fields: + category: 4 + is_main: true + subcategory: 20 model: utils.categorysubcategory pk: 20 -- fields: {category: 4, is_main: true, subcategory: 21} +- fields: + category: 4 + is_main: true + subcategory: 21 model: utils.categorysubcategory pk: 21 -- fields: {category: 4, is_main: true, subcategory: 22} +- fields: + category: 4 + is_main: true + subcategory: 22 model: utils.categorysubcategory pk: 22 -- fields: {category: 4, is_main: true, subcategory: 23} +- fields: + category: 4 + is_main: true + subcategory: 23 model: utils.categorysubcategory - pk: 23 \ No newline at end of file + pk: 23 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 37c958c9a2..35d5515e15 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -61,6 +61,9 @@ deactivate # Restart zds sudo systemctl restart zds.{service,socket} +# clean the cache by restarting it +sudo service memcached restart + # Exit maintenance mode sudo rm $ENV_PATH/webroot/maintenance.html diff --git a/scripts/install_resources.sh b/scripts/install_resources.sh index f6882ae6b3..70f3d5576e 100755 --- a/scripts/install_resources.sh +++ b/scripts/install_resources.sh @@ -1,7 +1,6 @@ #!/bin/bash -export RESOURCES_URL="http://www.googledrive.com/host/0BzabS14KitJgfmV2ekdWSktmVEpieU93TG11RFNkWlZqS0JwZk93ZGhMR1lCWVg5NzFVc00" -if [[ -f "$HOME/.fonts/truetype/Andale-Mono.ttf" ]]; then +if [[ -f "$HOME/.fonts/truetype/SourceCodePro/SourceCodePro-Regular.ttf" ]]; then echo "Using cached fonts" else # force cache upload after successful build @@ -9,11 +8,8 @@ else echo "Installing fonts" rm -rf $HOME/.fonts mkdir -p $HOME/.fonts/truetype - wget -P $HOME/.fonts/truetype $RESOURCES_URL/Andale-Mono.ttf - wget -P $HOME/.fonts/truetype $RESOURCES_URL/Merriweather.zip - unzip -u $HOME/.fonts/truetype/Merriweather.zip -d $HOME/.fonts/truetype/Merriweather/ - chmod a+r $HOME/.fonts/truetype/Merriweather/*.ttf - chmod a+r $HOME/.fonts/truetype/Andale-Mono.ttf + cp -r export-assets/fonts/* $HOME/.fonts/truetype + chmod a+r -R $HOME/.fonts/truetype/* echo "Installation complete !" fi # always refresh font cache because $HOME/.fonts has been added either here or via the cache @@ -28,9 +24,9 @@ else rm -rf $HOME/.cabal $HOME/.pandoc mkdir -p $HOME/.cabal/bin mkdir -p $HOME/.pandoc - wget -P $HOME/.cabal/bin $RESOURCES_URL/pandoc - wget -P $HOME/.pandoc/templates $RESOURCES_URL/default.epub - wget -P $HOME/.pandoc/templates $RESOURCES_URL/default.html + unzip -u export-assets/pandoc/pandoc.zip -d $HOME/.cabal/bin + mkdir -p $HOME/.pandoc/templates + cp export-assets/pandoc/default.* $HOME/.pandoc/templates ln -sf $HOME/.cabal/bin/pandoc $HOME/bin/ echo "Installation complete !" fi diff --git a/scripts/migrations/20160718_02_utf8mb4.sh b/scripts/migrations/20160718_02_utf8mb4.sh index 70012d151a..e66e08769f 100755 --- a/scripts/migrations/20160718_02_utf8mb4.sh +++ b/scripts/migrations/20160718_02_utf8mb4.sh @@ -33,7 +33,7 @@ mysql -u zds -p zdsdb << EOF ALTER TABLE \`auth_group\` ROW_FORMAT=DYNAMIC; ALTER TABLE \`auth_group_permissions\` ROW_FORMAT=DYNAMIC; ALTER TABLE \`auth_permission\` ROW_FORMAT=DYNAMIC; - # auth_user # this one stays + ALTER TABLE \`auth_user\` ROW_FORMAT=DYNAMIC; ALTER TABLE \`auth_user_groups\` ROW_FORMAT=DYNAMIC; ALTER TABLE \`auth_user_user_permissions\` ROW_FORMAT=DYNAMIC; ALTER TABLE \`corsheaders_corsmodel\` ROW_FORMAT=DYNAMIC; @@ -124,7 +124,7 @@ mysql -u zds -p zdsdb << EOF ALTER TABLE \`auth_group\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE \`auth_group_permissions\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE \`auth_permission\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - # auth_user # this one stays in utf8_bin + ALTER TABLE \`auth_user\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE \`auth_user_groups\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE \`auth_user_user_permissions\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE \`corsheaders_corsmodel\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -234,7 +234,12 @@ mysql -u zds -p zdsdb << EOF ALTER TABLE \`auth_permission\` CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; ALTER TABLE \`auth_permission\` CHANGE codename codename VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; - # auth_user # this one stays in utf8_bin + # auth_user + ALTER TABLE \`auth_user\` CHANGE password password VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; + ALTER TABLE \`auth_user\` CHANGE username username VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; + ALTER TABLE \`auth_user\` CHANGE first_name first_name VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; + ALTER TABLE \`auth_user\` CHANGE last_name last_name VARCHAR(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; + ALTER TABLE \`auth_user\` CHANGE email email VARCHAR(254) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; # corsheaders_corsmodel ALTER TABLE \`corsheaders_corsmodel\` CHANGE cors cors VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL; diff --git a/templates/base.html b/templates/base.html index d17e0d4eac..fc8f2cc660 100644 --- a/templates/base.html +++ b/templates/base.html @@ -353,7 +353,7 @@

diff --git a/templates/misc/message.part.html b/templates/misc/message.part.html index 2b0910170b..e39f8d43d9 100644 --- a/templates/misc/message.part.html +++ b/templates/misc/message.part.html @@ -3,6 +3,7 @@ {% load date %} {% load set %} {% load i18n %} +{% load pluralize_fr %} {% if topic.author == message.author and helpful_link %} @@ -43,7 +44,7 @@

{% endif %} @@ -235,37 +238,35 @@ {% if karma_link %}
- {% if user.is_authenticated and helpful_link and not is_author %} - {% if message.author != user or perms_change %} - {% if topic.author == user or perms_change %} -
- {% csrf_token %} -
+ {% endif %} {% endif %} {% if user.is_authenticated and user != message.author %}
diff --git a/templates/notification/followed.html b/templates/notification/followed.html index 0c9f117f45..8b5072a9f9 100644 --- a/templates/notification/followed.html +++ b/templates/notification/followed.html @@ -22,7 +22,7 @@ {% block content %} - {% include "misc/pagination.part.html" with position="top" %} + {% include "misc/paginator.html" with position="top" %}

+ {{ notification.pubdate|format_date:True|capfirst }} {{ notification.pubdate|format_date|capfirst }} {% trans "par" %} @@ -47,5 +48,5 @@

{{ notification.title {% endfor %}

- {% include "misc/pagination.part.html" with position="bottom" %} + {% include "misc/paginator.html" with position="bottom" %} {% endblock %} diff --git a/templates/notification/subscription_count_template.html b/templates/notification/subscription_count_template.html index 7e8ef941cf..617243fe17 100644 --- a/templates/notification/subscription_count_template.html +++ b/templates/notification/subscription_count_template.html @@ -1,3 +1,16 @@ {% load i18n %} +{% load pluralize_fr %} -{% blocktrans %}Il y a {{ subscriber_count }} abonné{{ subscriber_count|pluralize }}{% endblocktrans %} +{% if own_profile %} + {% blocktrans with plural=subscriber_count|pluralize_fr %} + Vous avez {{ subscriber_count }} abonné{{ plural }} + {% endblocktrans %} +{% elif own_profile == False %} + {% blocktrans with plural=subscriber_count|pluralize_fr %} + {{ subscriber_count }} abonné{{ plural }} + {% endblocktrans %} +{% else %} + {% blocktrans with plural=subscriber_count|pluralize_fr %} + ({{ subscriber_count }} abonné{{ plural }}) + {% endblocktrans %} +{% endif %} diff --git a/templates/tutorialv2/index.html b/templates/tutorialv2/index.html index 3736ae4c2c..4d5f580134 100644 --- a/templates/tutorialv2/index.html +++ b/templates/tutorialv2/index.html @@ -143,9 +143,6 @@

{% endblock %} {% block sidebar_actions %} -
  • - {% include 'notification/subscription_count_template.html' with subscriber_count=subscriber_count %} -
  • {% if usr != user and user.is_authenticated %}
  • {% with content_is_followed=usr|is_new_publication_followed %} @@ -157,7 +154,7 @@

    {% trans "Suivre ses publications" as button_text %} {% trans "Désinscription à ses publications" as data_onclick %} {% endif %} - {% include 'notification/follow_template.html' with link=link_follow is_followed=content_is_followed data_onclick=data_onclick button_text=button_text %} + {% include 'notification/follow_template.html' with link=link_follow is_followed=content_is_followed data_onclick=data_onclick button_text=button_text subscriber_count=subscriber_count %} {% endwith %}

  • diff --git a/templates/tutorialv2/messages/add_author_pm.md b/templates/tutorialv2/messages/add_author_pm.md index c43ced1d37..dd7f034c1e 100644 --- a/templates/tutorialv2/messages/add_author_pm.md +++ b/templates/tutorialv2/messages/add_author_pm.md @@ -1,6 +1,6 @@ {% load i18n %} -{% blocktrans with title=content.title|safe type=type|safe %} +{% blocktrans with title=content.title|safe type=type|safe user=user|safe %} Bonjour {{ user }}, Vous avez été intégré à la rédaction du contenu « [{{ title }}]({{ url }}) ». diff --git a/templates/tutorialv2/messages/beta_activate_pm.md b/templates/tutorialv2/messages/beta_activate_pm.md index 9ebad7706b..9858ba3762 100644 --- a/templates/tutorialv2/messages/beta_activate_pm.md +++ b/templates/tutorialv2/messages/beta_activate_pm.md @@ -1,6 +1,6 @@ {% load i18n %} -{% blocktrans with title=content.title|safe type=type|safe %} +{% blocktrans with title=content.title|safe type=type|safe user=user|safe %} Bonjour {{ user }}, Le contenu « {{ title }} » a été passé en bêta. La communauté pourra diff --git a/templates/tutorialv2/messages/beta_update.md b/templates/tutorialv2/messages/beta_update.md index e550775b05..a63f0f76ba 100644 --- a/templates/tutorialv2/messages/beta_update.md +++ b/templates/tutorialv2/messages/beta_update.md @@ -1,6 +1,6 @@ {% load i18n %} -{% blocktrans with title=content.title %} +{% blocktrans with title=content.title|safe %} Bonjour les agrumes ! diff --git a/templates/tutorialv2/messages/resolve_alert.md b/templates/tutorialv2/messages/resolve_alert.md index 88583736ee..95cf565fb4 100644 --- a/templates/tutorialv2/messages/resolve_alert.md +++ b/templates/tutorialv2/messages/resolve_alert.md @@ -1,6 +1,6 @@ {% load i18n %} -{% blocktrans with title=content.title|safe type_content=content.textual_type|safe message=message|safe user_name=target_name|safe modo_name=modo_name|safe alert_text=alert_text|safe %} +{% blocktrans with title=content.title|safe type_content=content.textual_type|safe message=message|safe user_name=target_name|safe modo_name=modo_name|safe name=name|safe alert_text=alert_text|safe %} Bonjour {{ name }}, diff --git a/templates/tutorialv2/messages/validation_cancel.md b/templates/tutorialv2/messages/validation_cancel.md index bd93b8b8ec..98568834a4 100644 --- a/templates/tutorialv2/messages/validation_cancel.md +++ b/templates/tutorialv2/messages/validation_cancel.md @@ -1,7 +1,7 @@ {% load i18n %} {% load captureas %} -{% blocktrans with title=content.title|safe user_url=user.get_absolute_url user_name=user.username|safe message=message|safe %} +{% blocktrans with title=content.title|safe user_url=user.get_absolute_url user_name=user.username|safe message=message|safe validator=validator|safe %} Bonjour {{ validator }}, diff --git a/templates/tutorialv2/messages/validation_cancel_on_delete.md b/templates/tutorialv2/messages/validation_cancel_on_delete.md index e3bbeb1fac..e83ade22a3 100644 --- a/templates/tutorialv2/messages/validation_cancel_on_delete.md +++ b/templates/tutorialv2/messages/validation_cancel_on_delete.md @@ -1,7 +1,7 @@ {% load i18n %} {% load captureas %} -{% blocktrans with title=content.title|safe user_url=user.get_absolute_url user_name=user.username|safe message=message|safe %} +{% blocktrans with title=content.title|safe user_url=user.get_absolute_url user_name=user.username|safe message=message|safe validator=validator|safe %} Bonjour {{ validator }}, diff --git a/templates/tutorialv2/messages/validation_change.md b/templates/tutorialv2/messages/validation_change.md index fc5bde2f96..f785329b97 100644 --- a/templates/tutorialv2/messages/validation_change.md +++ b/templates/tutorialv2/messages/validation_change.md @@ -5,7 +5,7 @@ {% url "validation:list" %} {% endcaptureas %} -{% blocktrans with title=content.title|safe %} +{% blocktrans with title=content.title|safe validator=validator|safe %} Ça pulpe {{ validator }} ? diff --git a/templates/tutorialv2/messages/validation_reserve.md b/templates/tutorialv2/messages/validation_reserve.md index c4e4837d7a..0161bd6079 100644 --- a/templates/tutorialv2/messages/validation_reserve.md +++ b/templates/tutorialv2/messages/validation_reserve.md @@ -1,6 +1,6 @@ {% load i18n %} -{% blocktrans with title=content.title %} +{% blocktrans with title=content.title|safe %} Salut ! @@ -8,4 +8,4 @@ Je viens de prendre en charge la validation de ton contenu, « [{{ title }}]({{ À bientôt ! -{% endblocktrans %} \ No newline at end of file +{% endblocktrans %} diff --git a/templates/tutorialv2/view/content_online.html b/templates/tutorialv2/view/content_online.html index 949206ed49..fabc4cad51 100644 --- a/templates/tutorialv2/view/content_online.html +++ b/templates/tutorialv2/view/content_online.html @@ -7,6 +7,7 @@ {% load i18n %} {% load crispy_forms_tags %} {% load interventions %} +{% load pluralize_fr %} {% block title %} @@ -133,9 +134,6 @@

    {% endblock %} {% block sidebar_actions %} -
  • - {% include 'notification/subscription_count_template.html' with subscriber_count=subscriber_count %} -
  • {% if user.is_authenticated %}
  • {% with content_is_followed=object|is_content_followed %} @@ -147,7 +145,7 @@

    {% trans "Suivre ce contenu" as button_text %} {% trans "Ne plus suivre ce contenu" as data_onclick %} {% endif %} - {% include 'notification/follow_template.html' with link=link_follow is_followed=content_is_followed data_onclick=data_onclick button_text=button_text %} + {% include 'notification/follow_template.html' with link=link_follow is_followed=content_is_followed data_onclick=data_onclick button_text=button_text subscriber_count=subscriber_count %} {% endwith %}

  • {% if not user in content.authors.all %} @@ -240,7 +238,7 @@

    {{ content.get_note_count }} - {% trans "commentaire" %}{{ content.get_note_count|pluralize }} + {% trans "commentaire" %}{{ content.get_note_count|pluralize_fr }} {% else %} {% trans "Aucun commentaire" %} {% endif %} diff --git a/templates/tutorialv2/view/tags.html b/templates/tutorialv2/view/tags.html index a83ce5f546..76a06be541 100644 --- a/templates/tutorialv2/view/tags.html +++ b/templates/tutorialv2/view/tags.html @@ -4,6 +4,7 @@ {% load date %} {% load i18n %} {% load captureas %} +{% load pluralize_fr %} {% block title %} @@ -29,7 +30,11 @@

    {% empty %} diff --git a/update.md b/update.md index c6d2edaa6e..519f8f0234 100644 --- a/update.md +++ b/update.md @@ -756,6 +756,44 @@ Issue 3762 (Ne pas oublier de lancer les migrations en terminant cette MEP !) +HTTP/2 +------ + +Installer une version plus récente de nginx qui supporte HTTP/2 et vérifier la configuration de nginx : + +``` +sudo apt-get update && apt-get -t jessie-backports install nginx openssl +sudo nginx -t +``` + +Pour chaque fichier dans `/etc/nginx/sites-enabled/`, remplacer spdy par http2: + +```diff +- listen 443 ssl spdy default_server; +- listen [::]:443 ssl spdy default_server; ++ listen 443 ssl http2 default_server; ++ listen [::]:443 ssl http2 default_server; +``` + +Relancer nginx : `sudo service nginx restart`` + +Vérifier que zds fonctionne comme il faut en HTTP et HTTPS. + +Polices pour les exports de contenus +------------------------------------ + +Utilisateur `zds`: + +1. `cp -r export-assets/fonts/* ~/.fonts/truetype` +1. `fc-cache -f -v > update-fonts-cache.log` +1. Vérifier dans les logs que les nouvelles polices ont été chargées. +1. Mettre à jour `settings_prod.py` : + + ```diff + - "-V mainfont=Merriweather -V monofont=\"Andale Mono\" " + + "-V mainfont=Merriweather -V monofont=\"SourceCodePro-Regular\" " + ``` + --- **Notes auxquelles penser lors de l'édition de ce fichier (à laisser en bas) :** diff --git a/zds/forum/forms.py b/zds/forum/forms.py index 52007c859f..1996fbd345 100644 --- a/zds/forum/forms.py +++ b/zds/forum/forms.py @@ -106,7 +106,8 @@ def clean(self): [_(u'Ce message est trop long, il ne doit pas dépasser {0} ' u'caractères').format(settings.ZDS_APP['forum']['max_post_length'])]) - if not TagValidator.validate_raw_string(cleaned_data.get("tags")): + validator = TagValidator() + if not validator.validate_raw_string(cleaned_data.get("tags")): self._errors['tags'] = self.error_class([_(u'Vous avez entré un tag trop long.')]) return cleaned_data diff --git a/zds/forum/migrations/0007_auto_20160827_2035.py b/zds/forum/migrations/0007_auto_20160827_2035.py new file mode 100644 index 0000000000..7514583433 --- /dev/null +++ b/zds/forum/migrations/0007_auto_20160827_2035.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forum', '0006_auto_20160720_2259'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='position', + field=models.IntegerField(default=0, verbose_name=b'Position'), + ), + ] diff --git a/zds/forum/models.py b/zds/forum/models.py index 38d9121b4a..212ea0b5b8 100644 --- a/zds/forum/models.py +++ b/zds/forum/models.py @@ -50,7 +50,7 @@ class Meta: ordering = ['position', 'title'] title = models.CharField('Titre', max_length=80) - position = models.IntegerField('Position', null=True, blank=True) + position = models.IntegerField('Position', default=0) # Some category slugs are forbidden due to path collisions: Category path is `/forums/` but some actions on # forums have path like `/forums/`. Forbidden slugs are all top-level path in forum's `url.py` module. # As Categories can only be managed by superadmin, this is purely declarative and there is no control on slug. @@ -464,22 +464,6 @@ def __unicode__(self): self.post.pk) -def get_last_topics(user): - """Returns the 5 very last topics.""" - # TODO semble inutilisé (et peu efficace dans la manière de faire) - topics = Topic.objects.all().order_by('-last_message__pubdate') - - tops = [] - cpt = 1 - for topic in topics: - if topic.forum.can_read(user): - tops.append(topic) - cpt += 1 - if cpt > 5: - break - return tops - - def is_read(topic, user=None): """ Checks if the user has read the **last post** of the topic. diff --git a/zds/forum/tests/tests.py b/zds/forum/tests/tests.py index 720bd32348..84b05a40bf 100644 --- a/zds/forum/tests/tests.py +++ b/zds/forum/tests/tests.py @@ -14,6 +14,7 @@ from zds.member.factories import ProfileFactory, StaffProfileFactory from zds.notification.models import TopicAnswerSubscription from zds.utils import slugify +from zds.utils.forums import get_tag_by_title from zds.utils.models import Alert, Tag from zds import settings as zds_settings @@ -568,9 +569,9 @@ def test_useful_post(self): # useful the first post result = self.client.post(reverse('post-useful') + '?message={0}'.format(post1.pk), follow=False) - self.assertEqual(result.status_code, 403) + self.assertEqual(result.status_code, 302) - self.assertEqual(Post.objects.get(pk=post1.pk).is_useful, False) + self.assertEqual(Post.objects.get(pk=post1.pk).is_useful, True) self.assertEqual(Post.objects.get(pk=post2.pk).is_useful, True) self.assertEqual(Post.objects.get(pk=post3.pk).is_useful, False) @@ -581,10 +582,10 @@ def test_useful_post(self): result = self.client.post(reverse('post-useful') + '?message={0}'.format(post5.pk), follow=False) - self.assertEqual(result.status_code, 403) + self.assertEqual(result.status_code, 302) self.assertEqual(Post.objects.get(pk=post4.pk).is_useful, False) - self.assertEqual(Post.objects.get(pk=post5.pk).is_useful, False) + self.assertEqual(Post.objects.get(pk=post5.pk).is_useful, True) # useful if you are staff StaffProfileFactory().user @@ -595,7 +596,7 @@ def test_useful_post(self): result = self.client.post(reverse('post-useful') + '?message={0}'.format(post4.pk), follow=False) self.assertNotEqual(result.status_code, 403) self.assertEqual(Post.objects.get(pk=post4.pk).is_useful, True) - self.assertEqual(Post.objects.get(pk=post5.pk).is_useful, False) + self.assertEqual(Post.objects.get(pk=post5.pk).is_useful, True) def test_failing_useful_post(self): """To test some failing cases when a member mark a post is useful.""" @@ -744,7 +745,6 @@ def test_add_tag(self): self.assertEqual(result.status_code, 302) # test the topic is added to the good tag - self.assertEqual(Topic.objects.filter( tags__in=[tag_c_sharp]) .order_by("-last_message__pubdate").prefetch_related( @@ -753,6 +753,27 @@ def test_add_tag(self): .order_by("-last_message__pubdate").prefetch_related( "tags").count(), 0) + topic_with_conflict_tags = TopicFactory( + forum=self.forum11, author=self.user) + topic_with_conflict_tags.title = u"[C][c][ c][C ]name" + (tags, title) = get_tag_by_title(topic_with_conflict_tags.title) + topic_with_conflict_tags.add_tags(tags) + self.assertEqual(topic_with_conflict_tags.tags.all().count(), 1) + + topic_with_conflict_tags = TopicFactory( + forum=self.forum11, author=self.user) + topic_with_conflict_tags.title = u"[][ ][ ]name" + (tags, title) = get_tag_by_title(topic_with_conflict_tags.title) + topic_with_conflict_tags.add_tags(tags) + self.assertEqual(topic_with_conflict_tags.tags.all().count(), 0) + + topic_with_utf8mb4_tags = TopicFactory( + forum=self.forum11, author=self.user) + topic_with_utf8mb4_tags.title = u"[🍆][tag987][🐙]name" + (tags, title) = get_tag_by_title(topic_with_utf8mb4_tags.title) + topic_with_utf8mb4_tags.add_tags(tags) + self.assertEqual(topic_with_utf8mb4_tags.tags.all().count(), 1) + def test_mandatory_fields_on_new(self): """Test handeling of mandatory fields on new topic creation.""" init_topic_count = Topic.objects.all().count() diff --git a/zds/forum/tests/tests_views.py b/zds/forum/tests/tests_views.py index 6390e94acd..48b8333957 100644 --- a/zds/forum/tests/tests_views.py +++ b/zds/forum/tests/tests_views.py @@ -224,6 +224,16 @@ def test_success_list_all_posts_of_a_topic(self): self.assertIsNotNone(response.context['form']) self.assertIsNotNone(response.context['form_move']) + def test_subscriber_count_of_a_topic(self): + profile = ProfileFactory() + category, forum = create_category() + topic = add_topic_in_a_forum(forum, profile) + + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + + self.assertEqual(200, response.status_code) + self.assertEqual(1, response.context['subscriber_count']) + class TopicNewTest(TestCase): def test_failure_create_topic_with_a_post_with_client_unauthenticated(self): @@ -1253,6 +1263,28 @@ def test_success_edit_post_alert_message(self): self.assertEqual(1, len(post.alerts.all())) self.assertEqual(text_expected, post.alerts.all()[0].text) + def test_failure_edit_post_hidden_message_by_non_staff(self): + """Test that a non staff cannot access the page to edit a hidden message""" + + profile = ProfileFactory() + category, forum = create_category() + topic = add_topic_in_a_forum(forum, profile) + + self.assertTrue(self.client.login(username=profile.user.username, password='hostel77')) + data = { + 'delete_message': '' + } + + response = self.client.post( + reverse('post-edit') + '?message={}'.format(topic.last_message.pk), data, follow=False) + self.assertEqual(302, response.status_code) + + response = self.client.get(reverse('post-edit') + '?message={}'.format(topic.last_message.pk)) + self.assertEqual(403, response.status_code) + + response = self.client.get(reverse('topic-edit') + '?topic={}'.format(topic.pk), follow=False) + self.assertEqual(403, response.status_code) + class PostUsefulTest(TestCase): def test_failure_post_useful_require_method_post(self): @@ -1312,7 +1344,7 @@ def test_failure_post_useful_its_post(self): self.assertTrue(self.client.login(username=profile.user.username, password='hostel77')) response = self.client.post(reverse('post-useful') + '?message={}'.format(topic.last_message.pk)) - self.assertEqual(403, response.status_code) + self.assertEqual(302, response.status_code) def test_failure_post_useful_when_not_author_of_topic(self): another_profile = ProfileFactory() @@ -1404,6 +1436,76 @@ def test_alert(self): alerts = [word for word in response.content.split() if word == 'alert'] self.assertEqual(len(alerts), 2) + def test_hide(self): + profile = ProfileFactory() + category, forum = create_category() + topic = add_topic_in_a_forum(forum, profile) + another_profile = ProfileFactory() + PostFactory(topic=topic, author=another_profile.user, position=2) + + # two posts are displayed + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + posts = [word for word in response.content.split() if word == 'm\'appelle'] + self.assertEqual(len(posts), 2) + + # unauthenticated, no 'Hide' button + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + self.assertNotContains(response, 'Masquer') + + # authenticated, only one 'Hide' buttons because our user only posted one of them + self.client.login(username=profile.user.username, password='hostel77') + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + hide_buttons = [word for word in response.content.split() if word == 'hide'] + self.assertEqual(len(hide_buttons), 1) + + # staff hides a message + staff = StaffProfileFactory() + self.assertTrue(self.client.login(username=staff.user.username, password='hostel77')) + text_hidden_expected = u'Bad guy!' + data = { + 'delete_message': '', + 'text_hidden': text_hidden_expected + } + response = self.client.post( + reverse('post-edit') + '?message={}'.format(topic.last_message.pk), data, follow=False) + self.assertEqual(302, response.status_code) + + # unauthenticated + # only one post is displayed, visitor can see hide reason and cannot show or re-enable + self.client.logout() + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + posts = [word for word in response.content.split() if word == 'm\'appelle'] + self.assertEqual(len(posts), 1) + self.assertNotContains(response, '#show-message-hidden-') + self.assertNotContains(response, 'Démasquer') + self.assertContains(response, 'Bad guy!') + + # user cannot show or re-enable their message + self.client.login(username=profile.user.username, password='hostel77') + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + self.assertNotContains(response, '#show-message-hidden-') + self.assertNotContains(response, 'Démasquer') + self.assertContains(response, 'Bad guy!') + + # staff can show or re-enable + self.assertTrue(self.client.login(username=staff.user.username, password='hostel77')) + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + self.assertContains(response, 'show-message-hidden-') + self.assertContains(response, 'Démasquer') + text_hidden_expected = u'Bad guy!' + data = { + 'show_message': '', + } + response = self.client.post( + reverse('post-edit') + '?message={}'.format(topic.last_message.pk), data, follow=False) + self.assertEqual(302, response.status_code) + + # two posts are displayed again + self.client.logout() + response = self.client.get(reverse('topic-posts-list', args=[topic.pk, topic.slug()])) + posts = [word for word in response.content.split() if word == 'm\'appelle'] + self.assertEqual(len(posts), 2) + class PostUnreadTest(TestCase): def test_failure_post_unread_require_method_get(self): diff --git a/zds/forum/views.py b/zds/forum/views.py index 80102c94fb..727ccc3831 100644 --- a/zds/forum/views.py +++ b/zds/forum/views.py @@ -25,7 +25,7 @@ from zds.forum.forms import TopicForm, PostForm, MoveTopicForm from zds.forum.models import Category, Forum, Topic, Post, is_read, mark_read, TopicRead from zds.member.decorator import can_write_and_read_now -from zds.notification.models import NewTopicSubscription, ContentReactionAnswerSubscription +from zds.notification.models import NewTopicSubscription, TopicAnswerSubscription from zds.utils import slugify from zds.utils.forums import create_topic, send_post, CreatePostView from zds.utils.mixins import FilterMixin @@ -148,7 +148,7 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super(TopicPostsListView, self).get_context_data(**kwargs) form = PostForm(self.object, self.request.user) - form.helper.form_action = reverse('post-new') + "?sujet=" + str(self.object.pk) + form.helper.form_action = reverse('post-new') + '?sujet=' + str(self.object.pk) context.update({ 'topic': self.object, @@ -159,20 +159,20 @@ def get_context_data(self, **kwargs): }) votes = CommentVote.objects.filter(user_id=self.request.user.pk, comment__in=context['posts']).all() - context["user_like"] = [vote.comment_id for vote in votes if vote.positive] - context["user_dislike"] = [vote.comment_id for vote in votes if not vote.positive] - context["is_staff"] = self.request.user.has_perm('forum.change_topic') + context['user_like'] = [vote.comment_id for vote in votes if vote.positive] + context['user_dislike'] = [vote.comment_id for vote in votes if not vote.positive] + context['is_staff'] = self.request.user.has_perm('forum.change_topic') context['isantispam'] = self.object.antispam() - context['subscriber_count'] = ContentReactionAnswerSubscription.objects.get_subscriptions(self.object).count() + context['subscriber_count'] = TopicAnswerSubscription.objects.get_subscriptions(self.object).count() if hasattr(self.request.user, 'profile'): context['is_dev'] = self.request.user.profile.is_dev() context['tags'] = settings.ZDS_APP['site']['repository']['tags'] context['has_token'] = self.request.user.profile.github_token != '' if self.request.user.has_perm('forum.change_topic'): - context["user_can_modify"] = [post.pk for post in context['posts']] + context['user_can_modify'] = [post.pk for post in context['posts']] else: - context["user_can_modify"] = [post.pk for post in context['posts'] if post.author == self.request.user] + context['user_can_modify'] = [post.pk for post in context['posts'] if post.author == self.request.user] if self.request.user.is_authenticated(): if not is_read(self.object): @@ -262,6 +262,8 @@ def dispatch(self, request, *args, **kwargs): if ('text' in request.POST or request.method == 'GET') \ and self.object.author != request.user and not request.user.has_perm('forum.change_topic'): raise PermissionDenied + if not self.object.first_post().is_visible and not request.user.has_perm('forum.change_topic'): + raise PermissionDenied if 'page' in request.POST: try: self.page = int(request.POST.get('page')) @@ -476,6 +478,8 @@ def dispatch(self, request, *args, **kwargs): if self.object.author != request.user and not request.user.has_perm( 'forum.change_post') and 'signal_message' not in request.POST: raise PermissionDenied + if not self.object.is_visible and not request.user.has_perm('forum.change_post'): + raise PermissionDenied return super(PostEdit, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): @@ -549,7 +553,7 @@ def dispatch(self, request, *args, **kwargs): self.object = self.get_object() if not self.object.topic.forum.can_read(request.user): raise PermissionDenied - if self.object.author == request.user or self.object.topic.author != request.user: + if self.object.topic.author != request.user: if not request.user.has_perm("forum.change_post"): raise PermissionDenied return super(PostUseful, self).dispatch(request, *args, **kwargs) diff --git a/zds/member/api/serializers.py b/zds/member/api/serializers.py index 9245d10720..1775b7cd5b 100644 --- a/zds/member/api/serializers.py +++ b/zds/member/api/serializers.py @@ -5,7 +5,7 @@ from zds.member.commons import ProfileCreate from zds.member.models import Profile -from zds.member.validators import ProfileUsernameValidator, ProfileEmailValidator +from zds.member.validators import validate_not_empty, validate_zds_username, validate_zds_email class UserListSerializer(serializers.ModelSerializer): @@ -42,15 +42,14 @@ class Meta: fields = ('id', 'username', 'html_url', 'is_active', 'date_joined', 'avatar_url', 'permissions') -class ProfileCreateSerializer(serializers.ModelSerializer, ProfileCreate, ProfileUsernameValidator, - ProfileEmailValidator): +class ProfileCreateSerializer(serializers.ModelSerializer, ProfileCreate): """ Serializers of a user object to create one. """ id = serializers.ReadOnlyField(source='user.id') - username = serializers.CharField(source='user.username') - email = serializers.EmailField(source='user.email') + username = serializers.CharField(source='user.username', validators=[validate_not_empty, validate_zds_username]) + email = serializers.EmailField(source='user.email', validators=[validate_not_empty, validate_zds_email]) password = serializers.CharField(source='user.password') permissions = DRYPermissionsField(additional_actions=['ban']) @@ -103,14 +102,16 @@ def __init__(self, *args, **kwargs): self.fields.pop('email') -class ProfileValidatorSerializer(serializers.ModelSerializer, ProfileUsernameValidator, ProfileEmailValidator): +class ProfileValidatorSerializer(serializers.ModelSerializer): """ Serializers of a profile object used to update a member. """ id = serializers.ReadOnlyField(source='user.id') - username = serializers.CharField(source='user.username', required=False, allow_blank=True) - email = serializers.EmailField(source='user.email', required=False, allow_blank=True) + username = serializers.CharField(source='user.username', required=False, allow_blank=True, + validators=[validate_not_empty, validate_zds_username]) + email = serializers.EmailField(source='user.email', required=False, allow_blank=True, + validators=[validate_not_empty, validate_zds_email]) is_active = serializers.BooleanField(source='user.is_active', required=False) date_joined = serializers.DateTimeField(source='user.date_joined', required=False) permissions = DRYPermissionsField(additional_actions=['ban']) diff --git a/zds/member/forms.py b/zds/member/forms.py index 6d9a8a387b..525d403ebe 100644 --- a/zds/member/forms.py +++ b/zds/member/forms.py @@ -5,7 +5,6 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import User, Group from django.core.urlresolvers import reverse -from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from captcha.fields import ReCaptchaField @@ -15,7 +14,8 @@ Submit, Field, ButtonHolder, Hidden, Div from zds.member.models import Profile, KarmaNote -from zds.member.validators import ProfileUsernameValidator, ProfileEmailValidator +from zds.member.validators import validate_not_empty, validate_zds_email, validate_zds_username, validate_passwords, \ + validate_zds_password from zds.utils.forms import CommonLayoutModalText # Max password length for the user. @@ -30,14 +30,14 @@ class LoginForm(forms.Form): The login form, including the "remember me" checkbox. """ username = forms.CharField( - label=_(u"Nom d'utilisateur"), + label=_(u'Nom d\'utilisateur'), max_length=User._meta.get_field('username').max_length, required=True, widget=forms.TextInput( attrs={ 'autofocus': '' } - ) + ), ) password = forms.CharField( @@ -71,7 +71,7 @@ def __init__(self, next=None, *args, **kwargs): ) -class RegisterForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator): +class RegisterForm(forms.Form): """ Form to register a new user. """ @@ -79,12 +79,14 @@ class RegisterForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator): label=_(u'Adresse courriel'), max_length=User._meta.get_field('email').max_length, required=True, + validators=[validate_not_empty, validate_zds_email], ) username = forms.CharField( label=_(u'Nom d\'utilisateur'), max_length=User._meta.get_field('username').max_length, required=True, + validators=[validate_not_empty, validate_zds_username], ) password = forms.CharField( @@ -92,7 +94,8 @@ class RegisterForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator): max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, required=True, - widget=forms.PasswordInput + widget=forms.PasswordInput, + validators=[validate_zds_password], ) password_confirm = forms.CharField( @@ -100,7 +103,8 @@ class RegisterForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator): max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, required=True, - widget=forms.PasswordInput + widget=forms.PasswordInput, + validators=[validate_zds_password], ) def __init__(self, *args, **kwargs): @@ -133,54 +137,8 @@ def __init__(self, *args, **kwargs): self.helper.layout = layout def clean(self): - """ - Cleans the input data and performs following checks: - - Both passwords are the same - - Username doesn't exist in database - - Username is not empty - - Username doesn't contain any comma (this will break the personal message system) - - Username doesn't begin or ends with spaces - - Password is different of username - - Email address is unique through all users - - Email provider is not a forbidden one - Forbidden email providers are stored in `forbidden_email_providers.txt` on project root. - :return: Cleaned data, and the error messages if they exist. - """ cleaned_data = super(RegisterForm, self).clean() - - # Check that the password and it's confirmation match - password = cleaned_data.get('password') - password_confirm = cleaned_data.get('password_confirm') - - if not password_confirm == password: - msg = _(u'Les mots de passe sont différents') - self._errors['password'] = self.error_class([msg]) - self._errors['password_confirm'] = self.error_class([msg]) - - if 'password' in cleaned_data: - del cleaned_data['password'] - - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - # Check that the user doesn't exist yet - username = cleaned_data.get('username') - self.validate_username(username) - - if username is not None: - # Check that password != username - if password == username: - msg = _(u'Le mot de passe doit être différent du pseudo') - self._errors['password'] = self.error_class([msg]) - if 'password' in cleaned_data: - del cleaned_data['password'] - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - email = cleaned_data.get('email') - self.validate_email(email) - - return cleaned_data + return validate_passwords(cleaned_data) def throw_error(self, key=None, message=None): self._errors[key] = self.error_class([message]) @@ -202,13 +160,12 @@ class MiniProfileForm(forms.Form): ) site = forms.CharField( - label='Site internet', + label='Site web', required=False, max_length=Profile._meta.get_field('site').max_length, widget=forms.TextInput( attrs={ - 'placeholder': _(u'Lien vers votre site internet ' - u'personnel (ne pas oublier le http:// ou https:// devant).') + 'placeholder': _(u'Lien vers votre site web personnel (ne pas oublier le http:// ou https:// devant).') } ) ) @@ -219,8 +176,7 @@ class MiniProfileForm(forms.Form): max_length=Profile._meta.get_field('avatar_url').max_length, widget=forms.TextInput( attrs={ - 'placeholder': _(u'Lien vers un avatar externe ' - u'(laissez vide pour utiliser Gravatar).') + 'placeholder': _(u'Lien vers un avatar externe laissez vide pour utiliser Gravatar).') } ) ) @@ -274,12 +230,11 @@ class ProfileForm(MiniProfileForm): label='', required=False, choices=( - ('show_email', _(u"Afficher mon adresse courriel publiquement")), - ('show_sign', _(u"Afficher les signatures")), - ('hover_or_click', _(u"Cochez pour dérouler les menus au survol")), - ('allow_temp_visual_changes', _(u"Activer les changements visuels temporaires")), - ('email_for_answer', _(u'Recevez un courriel lorsque vous ' - u'recevez une réponse à un message privé')), + ('show_email', _(u'Afficher mon adresse courriel publiquement')), + ('show_sign', _(u'Afficher les signatures')), + ('hover_or_click', _(u'Cochez pour dérouler les menus au survol')), + ('allow_temp_visual_changes', _(u'Activer les changements visuels temporaires')), + ('email_for_answer', _(u'Recevez un courriel lorsque vous recevez une réponse à un message privé')), ), widget=forms.CheckboxSelectMultiple, ) @@ -313,9 +268,9 @@ def __init__(self, *args, **kwargs): Field('biography'), Field('site'), Field('avatar_url'), - HTML(_(u"""

    Choisir un avatar dans une galerie
    + HTML(_(u'''

    Choisir un avatar dans une galerie
    Naviguez vers l'image voulue et cliquez sur le bouton "Choisir comme avatar".
    - Créez une galerie et importez votre avatar si ce n'est pas déjà fait !

    """)), + Créez une galerie et importez votre avatar si ce n'est pas déjà fait !

    ''')), Field('sign'), Field('options'), ButtonHolder(StrictButton(_(u'Enregistrer'), type='submit'),) @@ -325,7 +280,7 @@ def __init__(self, *args, **kwargs): self.helper.layout = layout -class ChangeUserForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator): +class ChangeUserForm(forms.Form): """ Update username and email """ @@ -339,6 +294,7 @@ class ChangeUserForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator 'placeholder': _(u'Ne mettez rien pour conserver l\'ancien') } ), + validators=[validate_not_empty, validate_zds_username], ) email = forms.EmailField( @@ -347,10 +303,10 @@ class ChangeUserForm(forms.Form, ProfileUsernameValidator, ProfileEmailValidator required=False, widget=forms.TextInput( attrs={ - 'placeholder': _(u'Ne mettez rien pour conserver l\'ancien') + 'placeholder': _(u'Ne mettez rien pour conserver l\'ancienne') } ), - error_messages={'invalid': _(u'Veuillez entrer une adresse email valide.'), } + validators=[validate_not_empty, validate_zds_email], ) def __init__(self, *args, **kwargs): @@ -367,36 +323,21 @@ def __init__(self, *args, **kwargs): ), ) - def clean(self): - cleaned_data = super(ChangeUserForm, self).clean() - - username_new = cleaned_data.get('username') - if username_new is not None: - self.validate_username(username_new) - - email_new = cleaned_data.get('email') - if email_new is not None: - self.validate_email(email_new) - - return cleaned_data - - def throw_error(self, key=None, message=None): - self._errors[key] = self.error_class([message]) - # TODO: Updates the password --> requires a better name class ChangePasswordForm(forms.Form): + password_old = forms.CharField( + label=_(u'Mot de passe actuel'), + widget=forms.PasswordInput, + ) + password_new = forms.CharField( label=_(u'Nouveau mot de passe'), max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, widget=forms.PasswordInput, - ) - - password_old = forms.CharField( - label=_(u'Mot de passe actuel'), - widget=forms.PasswordInput, + validators=[validate_zds_password], ) password_confirm = forms.CharField( @@ -404,6 +345,7 @@ class ChangePasswordForm(forms.Form): max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, widget=forms.PasswordInput, + validators=[validate_zds_password], ) def __init__(self, user, *args, **kwargs): @@ -427,56 +369,28 @@ def clean(self): cleaned_data = super(ChangePasswordForm, self).clean() password_old = cleaned_data.get('password_old') - password_new = cleaned_data.get('password_new') - password_confirm = cleaned_data.get('password_confirm') - # TODO: mutualizes these rules with registration ones? # Check if the actual password is not empty if password_old: - user_exist = authenticate( - username=self.user.username, password=password_old - ) + user_exist = authenticate(username=self.user.username, password=password_old) # Check if the user exist with old informations. - if not user_exist and password_old != "": - self._errors['password_old'] = self.error_class( - [_(u'Mot de passe incorrect.')]) + if not user_exist and password_old != '': + self._errors['password_old'] = self.error_class([_(u'Mot de passe incorrect.')]) if 'password_old' in cleaned_data: del cleaned_data['password_old'] - # Check that the password and it's confirmation match - if not password_confirm == password_new: - msg = _(u'Les mots de passe sont différents.') - self._errors['password_new'] = self.error_class([msg]) - self._errors['password_confirm'] = self.error_class([msg]) - - if 'password_new' in cleaned_data: - del cleaned_data['password_new'] - - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - # Check that password != username - if password_new == self.user.username: - msg = _(u'Le mot de passe doit être différent de votre pseudo') - self._errors['password_new'] = self.error_class([msg]) - if 'password_new' in cleaned_data: - del cleaned_data['password_new'] - - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - return cleaned_data + return validate_passwords(cleaned_data, password_label='password_new', username=self.user.username) class UsernameAndEmailForm(forms.Form): username = forms.CharField( label=_(u'Nom d\'utilisateur'), - required=False + required=False, ) email = forms.CharField( label=_(u'Adresse de courriel'), - required=False + required=False, ) def __init__(self, *args, **kwargs): @@ -509,23 +423,45 @@ def clean(self): username = cleaned_data.get('username') email = cleaned_data.get('email') - # Check that the username or the email is filled - if (username and email) or (not username and not email): - if username and email: - self._errors['username'] = self.error_class([_(u'Les deux champs ne doivent pas être rempli. ' - u'Remplissez soit l\'adresse de courriel soit le ' - u'nom d\'utilisateur')]) - else: - self._errors['username'] = self.error_class([_(u'Il vous faut remplir au moins un des deux champs')]) + if username and email: + self._errors['username'] = self.error_class([_(u'Seul un des deux champ doit être rempli. Remplissez soi' + u't l\'adresse de courriel soit le nom d\'utilisateur')]) + elif not username and not email: + self._errors['username'] = self.error_class([_(u'Il vous faut remplir au moins un des deux champs')]) else: - # Check if the user exist + # run validators if username: - if User.objects.filter(Q(username=username)).count() == 0: - self._errors['username'] = self.error_class([_(u'Ce nom d\'utilisateur n\'existe pas')]) - + validate_not_empty(username) + validate_zds_username(username) if email: - if User.objects.filter(Q(email=email)).count() == 0: - self._errors['email'] = self.error_class([_(u'Cette adresse de courriel n\'existe pas')]) + validate_not_empty(email) + validate_zds_email(email) + + return cleaned_data + + +class ForgotPasswordForm(UsernameAndEmailForm): + + def clean(self): + cleaned_data = super(UsernameAndEmailForm, self).clean() + + # Clean data + username = cleaned_data.get('username') + email = cleaned_data.get('email') + + if username and email: + self._errors['username'] = self.error_class([_(u'Les deux champs ne doivent pas être rempli. Remplissez soi' + u't l\'adresse de courriel soit le nom d\'utilisateur')]) + elif not username and not email: + self._errors['username'] = self.error_class([_(u'Il vous faut remplir au moins un des deux champs')]) + else: + # run validators + if username: + validate_not_empty(username) + validate_zds_username(username, check_username_available=False) + else: + validate_not_empty(email) + validate_zds_email(email, check_username_available=False) return cleaned_data @@ -538,13 +474,15 @@ class NewPasswordForm(forms.Form): label=_(u'Mot de passe'), max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, - widget=forms.PasswordInput + widget=forms.PasswordInput, + validators=[validate_zds_password], ) password_confirm = forms.CharField( label=_(u'Confirmation'), max_length=MAX_PASSWORD_LENGTH, min_length=MIN_PASSWORD_LENGTH, - widget=forms.PasswordInput + widget=forms.PasswordInput, + validators=[validate_zds_password], ) def __init__(self, identifier, *args, **kwargs): @@ -564,34 +502,7 @@ def __init__(self, identifier, *args, **kwargs): def clean(self): cleaned_data = super(NewPasswordForm, self).clean() - - # Check that the password and it's confirmation match - password = cleaned_data.get('password') - password_confirm = cleaned_data.get('password_confirm') - - # TODO: mutualizes these rules with registration ones? - if not password_confirm == password: - msg = _(u'Les mots de passe sont différents') - self._errors['password'] = self.error_class(['']) - self._errors['password_confirm'] = self.error_class([msg]) - - if 'password' in cleaned_data: - del cleaned_data['password'] - - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - # Check that password != username - if password == self.username: - msg = _(u'Le mot de passe doit être différent de votre pseudo') - self._errors['password'] = self.error_class([msg]) - if 'password' in cleaned_data: - del cleaned_data['password'] - - if 'password_confirm' in cleaned_data: - del cleaned_data['password_confirm'] - - return cleaned_data + return validate_passwords(cleaned_data, username=self.username) class PromoteMemberForm(forms.Form): @@ -599,18 +510,18 @@ class PromoteMemberForm(forms.Form): Promotes a user to an arbitrary group """ groups = forms.ModelMultipleChoiceField( - label=_(u"Groupe de l'utilisateur"), + label=_(u'Groupe de l\'utilisateur'), queryset=Group.objects.all(), required=False, ) superuser = forms.BooleanField( - label=_(u"Super-user"), + label=_(u'Droits superuser (accès complet à l\'administration Django et donc à la base de données)'), required=False, ) activation = forms.BooleanField( - label=_(u"Compte actif"), + label=_(u'Compte actif'), required=False, ) @@ -630,7 +541,7 @@ def __init__(self, *args, **kwargs): class KarmaForm(forms.Form): warning = forms.CharField( - label=_(u"Commentaire"), + label=_(u'Commentaire'), max_length=KarmaNote._meta.get_field('comment').max_length, widget=forms.TextInput( attrs={ diff --git a/zds/member/tests/tests_forms.py b/zds/member/tests/tests_forms.py index cdaf01268b..10812f16a1 100644 --- a/zds/member/tests/tests_forms.py +++ b/zds/member/tests/tests_forms.py @@ -2,10 +2,8 @@ from django.test import TestCase from zds.member.factories import ProfileFactory, NonAsciiProfileFactory -from zds.member.forms import LoginForm, RegisterForm, \ - MiniProfileForm, ProfileForm, ChangeUserForm, \ - ChangePasswordForm, NewPasswordForm, \ - KarmaForm, UsernameAndEmailForm +from zds.member.forms import LoginForm, RegisterForm, MiniProfileForm, ProfileForm, ChangeUserForm, ChangePasswordForm,\ + NewPasswordForm, KarmaForm, ForgotPasswordForm stringof77chars = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789-----" stringof251chars = u'abcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxy' \ @@ -81,7 +79,7 @@ def test_empty_email_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_empty_pseudo_register_form(self): + def test_empty_username_register_form(self): data = { 'email': 'test@gmail.com', 'username': '', @@ -91,7 +89,17 @@ def test_empty_pseudo_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_empty_spaces_pseudo_register_form(self): + def test_utf8mb4_username_register_form(self): + data = { + 'email': 'test@gmail.com', + 'username': '🐙', + 'password': 'ZePassword', + 'password_confirm': 'ZePassword' + } + form = RegisterForm(data=data) + self.assertFalse(form.is_valid()) + + def test_empty_spaces_username_register_form(self): data = { 'email': 'test@gmail.com', 'username': ' ', @@ -141,7 +149,7 @@ def test_too_long_password_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_password_match_pseudo_password_register_form(self): + def test_password_match_username_password_register_form(self): data = { 'email': 'test@gmail.com', 'username': 'ZeTester', @@ -151,7 +159,7 @@ def test_password_match_pseudo_password_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_pseudo_exist_register_form(self): + def test_username_exist_register_form(self): testuser = ProfileFactory() data = { 'email': 'test@gmail.com', @@ -173,7 +181,7 @@ def test_email_exist_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_pseudo_espaces_register_form(self): + def test_username_spaces_register_form(self): ProfileFactory() data = { 'email': 'test@gmail.com', @@ -184,7 +192,7 @@ def test_pseudo_espaces_register_form(self): form = RegisterForm(data=data) self.assertFalse(form.is_valid()) - def test_pseudo_coma_register_form(self): + def test_username_coma_register_form(self): ProfileFactory() data = { 'email': 'test@gmail.com', @@ -260,13 +268,13 @@ def test_valid_profile_form(self): class ChangeUserFormTest(TestCase): """ - Check the user pseudo/email. + Check the user username/email. """ def setUp(self): self.user1 = ProfileFactory() - def test_valid_change_pseudo_user_form(self): + def test_valid_change_username_user_form(self): data = { 'username': "MyNewPseudo", 'email': '' @@ -342,7 +350,7 @@ def test_wrong_email_user_form(self): form = ChangeUserForm(data=data) self.assertFalse(form.is_valid()) - def test_pseudo_espaces_register_form(self): + def test_username_spaces_register_form(self): ProfileFactory() data = { 'username': ' ZeTester ', @@ -351,7 +359,7 @@ def test_pseudo_espaces_register_form(self): form = ChangeUserForm(data=data) self.assertFalse(form.is_valid()) - def test_pseudo_coma_register_form(self): + def test_username_coma_register_form(self): ProfileFactory() data = { 'username': 'Ze,Tester', @@ -368,14 +376,14 @@ class ChangePasswordFormTest(TestCase): def setUp(self): self.user1 = ProfileFactory() - self.oldpassword = "hostel77" - self.newpassword = "TheNewPassword" + self.old_password = 'hostel77' + self.new_password = 'TheNewPassword' def test_valid_change_password_form(self): data = { - 'password_old': self.oldpassword, - 'password_new': self.newpassword, - 'password_confirm': self.newpassword + 'password_old': self.old_password, + 'password_new': self.new_password, + 'password_confirm': self.new_password } form = ChangePasswordForm(data=data, user=self.user1.user) self.assertTrue(form.is_valid()) @@ -383,44 +391,44 @@ def test_valid_change_password_form(self): def test_old_wrong_change_password_form(self): data = { 'password_old': 'Wronnnng', - 'password_new': self.newpassword, - 'password_confirm': self.newpassword + 'password_new': self.new_password, + 'password_confirm': self.new_password } form = ChangePasswordForm(data=data, user=self.user1.user) self.assertFalse(form.is_valid()) def test_not_matching_change_password_form(self): data = { - 'password_old': self.oldpassword, - 'password_new': self.newpassword, + 'password_old': self.old_password, + 'password_new': self.new_password, 'password_confirm': 'Wronnnng' } form = ChangePasswordForm(data=data, user=self.user1.user) self.assertFalse(form.is_valid()) def test_too_short_change_password_form(self): - tooshort = "short" + too_short = 'short' data = { - 'password_old': self.oldpassword, - 'password_new': tooshort, - 'password_confirm': tooshort + 'password_old': self.old_password, + 'password_new': too_short, + 'password_confirm': too_short } form = ChangePasswordForm(data=data, user=self.user1.user) self.assertFalse(form.is_valid()) def test_too_long_change_password_form(self): data = { - 'password_old': self.oldpassword, + 'password_old': self.old_password, 'password_new': stringof77chars, 'password_confirm': stringof77chars } form = ChangePasswordForm(data=data, user=self.user1.user) self.assertFalse(form.is_valid()) - def test_match_pseudo_change_password_form(self): - self.user1.user.username = "LongName" + def test_match_username_change_password_form(self): + self.user1.user.username = 'LongName' data = { - 'password_old': self.oldpassword, + 'password_old': self.old_password, 'password_new': self.user1.user.username, 'password_confirm': self.user1.user.username } @@ -442,7 +450,7 @@ def test_valid_forgot_password_form(self): 'username': self.user1.user.username, 'email': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertTrue(form.is_valid()) def test_non_valid_non_ascii_forgot_password_form(self): @@ -450,7 +458,7 @@ def test_non_valid_non_ascii_forgot_password_form(self): 'username': self.userNonAscii.user.username, 'email': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertTrue(form.is_valid()) def test_non_valid_non_ascii_email_forgot_password_form(self): @@ -458,7 +466,7 @@ def test_non_valid_non_ascii_email_forgot_password_form(self): 'username': '', 'email': self.userNonAscii.user.email } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertTrue(form.is_valid()) def test_valid_email_forgot_password_form(self): @@ -466,7 +474,7 @@ def test_valid_email_forgot_password_form(self): 'email': self.user1.user.email, 'username': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertTrue(form.is_valid()) def test_empty_name_forgot_password_form(self): @@ -474,7 +482,7 @@ def test_empty_name_forgot_password_form(self): 'username': '', 'email': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertFalse(form.is_valid()) def test_full_forgot_password_form(self): @@ -482,7 +490,7 @@ def test_full_forgot_password_form(self): 'username': 'John Doe', 'email': 'john.doe@gmail.com' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertFalse(form.is_valid()) def test_unknow_username_forgot_password_form(self): @@ -490,7 +498,7 @@ def test_unknow_username_forgot_password_form(self): 'username': 'John Doe', 'email': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertFalse(form.is_valid()) def test_unknow_email_forgot_password_form(self): @@ -498,7 +506,7 @@ def test_unknow_email_forgot_password_form(self): 'email': 'john.doe@gmail.com', 'username': '' } - form = UsernameAndEmailForm(data=data) + form = ForgotPasswordForm(data=data) self.assertFalse(form.is_valid()) @@ -509,7 +517,7 @@ class NewPasswordFormTest(TestCase): def setUp(self): self.user1 = ProfileFactory() - self.newpassword = "TheNewPassword" + self.newpassword = 'TheNewPassword' def test_valid_new_password_form(self): data = { @@ -522,7 +530,7 @@ def test_valid_new_password_form(self): def test_not_matching_new_password_form(self): data = { 'password': self.newpassword, - 'password_confirm': "Wronnngggg" + 'password_confirm': 'Wronnngggg' } form = NewPasswordForm(data=data, identifier=self.user1.user.username) self.assertFalse(form.is_valid()) @@ -536,10 +544,10 @@ def test_password_is_username_new_password_form(self): self.assertFalse(form.is_valid()) def test_password_too_short_new_password_form(self): - tooshort = "short" + too_short = 'short' data = { - 'password': tooshort, - 'password_confirm': tooshort + 'password': too_short, + 'password_confirm': too_short } form = NewPasswordForm(data=data, identifier=self.user1.user.username) self.assertFalse(form.is_valid()) diff --git a/zds/member/tests/tests_views.py b/zds/member/tests/tests_views.py index 475a21827f..d08eb22d55 100644 --- a/zds/member/tests/tests_views.py +++ b/zds/member/tests/tests_views.py @@ -1,7 +1,11 @@ # coding: utf-8 +from datetime import datetime import os import shutil + +from oauth2_provider.models import AccessToken, Application + from django.conf import settings from django.contrib.auth.models import User, Group from django.core import mail @@ -412,6 +416,25 @@ def test_unregister(self): private_topic.save() PrivatePostFactory(author=user.user, privatetopic=private_topic, position_in_topic=1) + # add API key + self.assertEqual(Application.objects.count(), 0) + self.assertEqual(AccessToken.objects.count(), 0) + api_application = Application() + api_application.client_id = 'foobar' + api_application.user = user.user + api_application.client_type = 'confidential' + api_application.authorization_grant_type = 'password' + api_application.client_secret = '42' + api_application.save() + token = AccessToken() + token.user = user.user + token.token = 'r@d0m' + token.application = api_application + token.expires = datetime.now() + token.save() + self.assertEqual(Application.objects.count(), 1) + self.assertEqual(AccessToken.objects.count(), 1) + # login and unregister: login_check = self.client.login( username=user.user.username, @@ -511,6 +534,10 @@ def test_unregister(self): self.assertTrue(Topic.objects.get(pk=beta_content.beta_topic.pk).is_locked) self.assertFalse(Topic.objects.get(pk=beta_content_2.beta_topic.pk).is_locked) + # check API + self.assertEqual(Application.objects.count(), 0) + self.assertEqual(AccessToken.objects.count(), 0) + def test_forgot_password(self): """To test nominal scenario of a lost password.""" diff --git a/zds/member/validators.py b/zds/member/validators.py index cf8a0e17a5..be0d821724 100644 --- a/zds/member/validators.py +++ b/zds/member/validators.py @@ -1,67 +1,147 @@ # -*- coding: utf-8 -*- + import os + from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.validators import EmailValidator +from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ -from zds.api.validators import Validator + +from zds.utils.misc import contains_utf8mb4 from zds.settings import BASE_DIR -class ProfileUsernameValidator(Validator): +def validate_not_empty(value): """ - Validates username field of a profile. + Fields cannot be empty or only contain spaces. + + :param value: value to validate (str or None) + :return: """ + if value is None or value.strip() == '': + raise ValidationError(_(u'Le champs ne peut être vide')) + - def validate_username(self, value): - """ - Checks about the username. - - :param value: username value - :type value: string - :return: username value - :rtype: string - """ - msg = None - if value: - if value.strip() == '': - msg = _(u'Le nom d\'utilisateur ne peut-être vide') - # Forbid the use of comma in the username - elif "," in value: - msg = _(u'Le nom d\'utilisateur ne peut contenir de virgules') - elif value != value.strip(): - msg = _(u'Le nom d\'utilisateur ne peut commencer/finir par des espaces') - elif User.objects.filter(username=value).count() > 0: - msg = _(u'Ce nom d\'utilisateur est déjà utilisé') - if msg is not None: - self.throw_error('username', msg) - return value - - -class ProfileEmailValidator(Validator): +class ZdSEmailValidator(EmailValidator): """ - Validates email field of a profile. + Based on https://docs.djangoproject.com/en/1.8/_modules/django/core/validators/#EmailValidator + Changed : + - check if provider is not if blacklisted + - check if email is not used by another user + - remove whitelist check + - add custom errors and translate them into French + """ + message = _(u'Utilisez une adresse de courriel valide.') + + def __call__(self, value, check_username_available=True): + value = force_text(value) + + if not value or '@' not in value: + raise ValidationError(self.message, code=self.code) + + user_part, domain_part = value.rsplit('@', 1) + + if not self.user_regex.match(user_part) or contains_utf8mb4(user_part): + raise ValidationError(self.message, code=self.code) + + # check if provider is blacklisted + with open(os.path.join(BASE_DIR, 'forbidden_email_providers.txt'), 'r') as black_list: + for provider in black_list: + if provider.strip() in value: + raise ValidationError(_(u'Utilisez un autre fournisseur d\'adresses courriel'), code=self.code) + + # check if email is used by another user + user_count = User.objects.filter(email=value).count() + if check_username_available and user_count > 0: + raise ValidationError(_(u'Cette adresse courriel est déjà utilisée'), code=self.code) + # check if email exists in database + elif not check_username_available and user_count == 0: + raise ValidationError(_(u'Cette adresse courriel n\'existe pas'), code=self.code) + + if domain_part and not self.validate_domain_part(domain_part): + # Try for possible IDN domain-part + try: + domain_part = domain_part.encode('idna').decode('ascii') + if self.validate_domain_part(domain_part): + return + except UnicodeError: + pass + raise ValidationError(self.message, code=self.code) + + +validate_zds_email = ZdSEmailValidator() + + +def validate_zds_username(value, check_username_available=True): """ + Check if username is used by another user + + :param value: value to validate (str or None) + :return: + """ + msg = None + user_count = User.objects.filter(username=value).count() + if ',' in value: + msg = _(u'Le nom d\'utilisateur ne peut contenir de virgules') + elif value != value.strip(): + msg = _(u'Le nom d\'utilisateur ne peut commencer ou finir par des espaces') + elif contains_utf8mb4(value): + msg = _(u'Le nom d\'utilisateur ne peut pas contenir des caractères utf8mb4') + elif check_username_available and user_count > 0: + msg = _(u'Ce nom d\'utilisateur est déjà utilisé') + elif not check_username_available and user_count == 0: + msg = _(u'Ce nom d\'utilisateur n\'existe pas') + if msg is not None: + raise ValidationError(msg) + + +def validate_zds_password(value): + """ + + :param value: + :return: + """ + if contains_utf8mb4(value): + raise ValidationError(_(u'Le mot de passe ne peut pas contenir des caractères utf8mb4')) + + +def validate_passwords(cleaned_data, password_label='password', password_confirm_label='password_confirm', + username=None): + """ + Chek if cleaned_data['password'] == cleaned_data['password_confirm'] and password is not username. + :param cleaned_data: + :param password_label: + :param password_confirm_label: + :return: + """ + + password = cleaned_data.get(password_label) + password_confirm = cleaned_data.get(password_confirm_label) + msg = None + + if username is None: + username = cleaned_data.get('username') + + if not password_confirm == password: + msg = _(u'Les mots de passe sont différents') + + if password_label in cleaned_data: + del cleaned_data[password_label] + + if password_confirm_label in cleaned_data: + del cleaned_data[password_confirm_label] + + if username is not None: + # Check that password != username + if password == username: + msg = _(u'Le mot de passe doit être différent du pseudo') + if password_label in cleaned_data: + del cleaned_data[password_label] + if password_confirm_label in cleaned_data: + del cleaned_data[password_confirm_label] + + if msg is not None: + raise ValidationError(msg) - def validate_email(self, value): - """ - Checks about the email. - - :param value: email value - :type value: string - :return: email value - :rtype: string - """ - if value: - msg = None - # Chech if email provider is authorized - with open(os.path.join(BASE_DIR, 'forbidden_email_providers.txt'), 'r') as black_list: - for provider in black_list: - if provider.strip() in value: - msg = _(u'Utilisez un autre fournisseur d\'adresses courriel.') - break - - # Check that the email is unique - if User.objects.filter(email=value).count() > 0: - msg = _(u'Votre adresse courriel est déjà utilisée') - if msg is not None: - self.throw_error('email', msg) - return value + return cleaned_data diff --git a/zds/member/views.py b/zds/member/views.py index 3a75baa5b8..3288b37fcc 100644 --- a/zds/member/views.py +++ b/zds/member/views.py @@ -3,6 +3,8 @@ import uuid from datetime import datetime, timedelta +from oauth2_provider.models import AccessToken + from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, login, logout @@ -32,7 +34,7 @@ from zds.member.decorator import can_write_and_read_now from zds.member.forms import LoginForm, MiniProfileForm, ProfileForm, RegisterForm, \ ChangePasswordForm, ChangeUserForm, NewPasswordForm, \ - PromoteMemberForm, KarmaForm, UsernameAndEmailForm + PromoteMemberForm, KarmaForm, UsernameAndEmailForm, ForgotPasswordForm from zds.member.models import Profile, TokenForgotPassword, TokenRegister, KarmaNote from zds.mp.models import PrivatePost, PrivateTopic from zds.tutorialv2.models.models_database import PublishableContent @@ -433,6 +435,10 @@ def unregister(request): anonymous_gallery.save() gallery.delete() + # remove API access (tokens + applications) + for token in AccessToken.objects.filter(user=current): + token.revoke() + logout(request) User.objects.filter(pk=current.pk).delete() return redirect(reverse("homepage")) @@ -707,7 +713,7 @@ def forgot_password(request): """If the user forgot his password, he can have a new one.""" if request.method == "POST": - form = UsernameAndEmailForm(request.POST) + form = ForgotPasswordForm(request.POST) if form.is_valid(): # Get data from form @@ -715,7 +721,7 @@ def forgot_password(request): username = data["username"] email = data["email"] - # Fetch the user, we need his email adress + # Fetch the user, we need his email address usr = None if username: usr = get_object_or_404(User, Q(username=username)) @@ -751,7 +757,7 @@ def forgot_password(request): else: return render(request, "member/forgot_password/index.html", {"form": form}) - form = UsernameAndEmailForm() + form = ForgotPasswordForm() return render(request, "member/forgot_password/index.html", {"form": form}) diff --git a/zds/mp/models.py b/zds/mp/models.py index 48fe33b56d..6970bdcf88 100644 --- a/zds/mp/models.py +++ b/zds/mp/models.py @@ -155,7 +155,7 @@ def alone(self): """ return self.participants.count() == 0 - def never_read(self, user=None): + def is_unread(self, user=None): """ Check if an user has never read the current PrivateTopic. @@ -168,7 +168,7 @@ def never_read(self, user=None): if user is None: user = get_current_user() - return never_privateread(self, user) + return is_privatetopic_unread(self, user) def is_author(self, user): """ @@ -307,7 +307,7 @@ def __unicode__(self): return u''.format(self.privatetopic, self.user, self.privatepost.pk) -def never_privateread(privatetopic, user=None): +def is_privatetopic_unread(privatetopic, user=None): """ Check if a private topic has been read by an user since it last post was added. diff --git a/zds/mp/tests/tests_models.py b/zds/mp/tests/tests_models.py index d0a325d77e..10132c8afa 100644 --- a/zds/mp/tests/tests_models.py +++ b/zds/mp/tests/tests_models.py @@ -6,7 +6,7 @@ from zds.member.factories import ProfileFactory from zds.mp.factories import PrivateTopicFactory, PrivatePostFactory -from zds.mp.models import mark_read, never_privateread, PrivateTopicRead +from zds.mp.models import mark_read, is_privatetopic_unread, PrivateTopicRead from zds import settings # by moment, i wrote the scenario to be simpler @@ -120,13 +120,13 @@ def test_never_read(self): # scenario - topic1 : # post1 - user1 - unread # post2 - user2 - unread - self.assertTrue(self.topic1.never_read(self.profile1.user)) + self.assertTrue(self.topic1.is_unread(self.profile1.user)) # scenario - topic1 : # post1 - user1 - read # post2 - user2 - read mark_read(self.topic1, self.profile1.user) - self.assertFalse(self.topic1.never_read(self.profile1.user)) + self.assertFalse(self.topic1.is_unread(self.profile1.user)) # scenario - topic1 : # post1 - user1 - read @@ -137,7 +137,7 @@ def test_never_read(self): author=self.profile2.user, position_in_topic=3) - self.assertTrue(self.topic1.never_read(self.profile1.user)) + self.assertTrue(self.topic1.is_unread(self.profile1.user)) def test_topic_never_read_get_last_read(self): """ Trying to read last message of a never read Private Topic @@ -242,18 +242,18 @@ def setUp(self): position_in_topic=2) def test_never_privateread(self): - self.assertTrue(never_privateread(self.topic1, self.profile1.user)) + self.assertTrue(is_privatetopic_unread(self.topic1, self.profile1.user)) mark_read(self.topic1, self.profile1.user) - self.assertFalse(never_privateread(self.topic1, self.profile1.user)) + self.assertFalse(is_privatetopic_unread(self.topic1, self.profile1.user)) def test_mark_read(self): - self.assertTrue(self.topic1.never_read(self.profile1.user)) + self.assertTrue(self.topic1.is_unread(self.profile1.user)) # scenario - topic1 : # post1 - user1 - read # post2 - user2 - read mark_read(self.topic1, self.profile1.user) - self.assertFalse(self.topic1.never_read(self.profile1.user)) + self.assertFalse(self.topic1.is_unread(self.profile1.user)) # scenario - topic1 : # post1 - user1 - read @@ -263,4 +263,4 @@ def test_mark_read(self): privatetopic=self.topic1, author=self.profile2.user, position_in_topic=3) - self.assertTrue(self.topic1.never_read(self.profile1.user)) + self.assertTrue(self.topic1.is_unread(self.profile1.user)) diff --git a/zds/mp/tests/tests_views.py b/zds/mp/tests/tests_views.py index a36ec2c0f3..a21c295e64 100644 --- a/zds/mp/tests/tests_views.py +++ b/zds/mp/tests/tests_views.py @@ -217,6 +217,26 @@ def test_get_page_too_far(self): }) + '?page=42') self.assertEqual(response.status_code, 404) + def test_available_actions(self): + """we should be able to cite, but not hide or alert""" + + login_check = self.client.login( + username=self.profile1.user.username, + password='hostel77' + ) + self.assertTrue(login_check) + + response = self.client.get(reverse('private-posts-list', + kwargs={'pk': self.topic1.pk, + 'topic_slug': self.topic1.slug, + })) + # Citation button + self.assertContains(response, 'Citer') + # no Alert button + self.assertNotContains(response, 'Signaler') + # no Hide button + self.assertNotContains(response, 'Masquer') + def test_more_than_one_message(self): """ test get second page """ diff --git a/zds/notification/api/tests.py b/zds/notification/api/tests.py index 29b6a36b0f..0ab86f11de 100644 --- a/zds/notification/api/tests.py +++ b/zds/notification/api/tests.py @@ -10,6 +10,7 @@ from zds.member.api.tests import create_oauth2_client, authenticate_client from zds.member.factories import ProfileFactory from zds.mp.factories import PrivateTopicFactory +from zds.notification.models import Notification from zds.utils.mps import send_message_mp @@ -38,7 +39,6 @@ def test_list_of_notifications(self): """ self.create_notification_for_pm(ProfileFactory().user, self.profile.user) response = self.client.get(reverse('api:notification:list')) - print response self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('count'), 1) @@ -60,7 +60,48 @@ def test_apply_filter_on_notifications(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('count'), 0) + def test_list_of_all_notifications(self): + """ + Gets list of read and unread notifications. + """ + topic1 = self.create_notification_for_pm(ProfileFactory().user, self.profile.user) + self.create_notification_for_pm(ProfileFactory().user, self.profile.user) + + notification = Notification.objects.get(object_id=topic1.last_message.pk, is_read=False) + notification.is_read = True + notification.save() + + response = self.client.get(reverse('api:notification:list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 2) + + def test_invalid_cache_when_update_a_notification(self): + """ + When a notification is updated, the cache should be invalidated. + """ + another_profile = ProfileFactory() + topic = self.create_notification_for_pm(another_profile.user, self.profile.user) + + response = self.client.get(reverse('api:notification:list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + notification_from_response = response.data.get('results')[0] + self.assertFalse(notification_from_response.get('is_read')) + + notification = Notification.objects.get(object_id=topic.last_message.pk, is_read=False) + notification.is_read = True + notification.save() + + response = self.client.get(reverse('api:notification:list')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), 1) + + notification_from_response = response.data.get('results')[0] + self.assertTrue(notification_from_response.get('is_read')) + def create_notification_for_pm(self, sender, target): topic = PrivateTopicFactory(author=sender) topic.participants.add(target) send_message_mp(author=sender, n_topic=topic, text='Testing') + return topic diff --git a/zds/notification/api/views.py b/zds/notification/api/views.py index 0830463e61..7478361d04 100644 --- a/zds/notification/api/views.py +++ b/zds/notification/api/views.py @@ -1,4 +1,8 @@ # coding: utf-8 +import datetime +from django.core.cache import cache +from django.db.models.signals import post_delete +from django.db.models.signals import post_save from dry_rest_permissions.generics import DRYPermissions from rest_framework import filters from rest_framework.generics import ListAPIView @@ -8,7 +12,7 @@ from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.key_constructor.constructors import DefaultKeyConstructor -from zds.api.bits import DJRF3xPaginationKeyBit +from zds.api.bits import DJRF3xPaginationKeyBit, UpdatedAtKeyBit from zds.notification.api.serializers import NotificationSerializer from zds.notification.models import Notification @@ -19,6 +23,15 @@ class PagingNotificationListKeyConstructor(DefaultKeyConstructor): list_sql_query = bits.ListSqlQueryKeyBit() unique_view_id = bits.UniqueViewIdKeyBit() user = bits.UserKeyBit() + updated_at = UpdatedAtKeyBit('api_updated_notification') + + +def change_api_notification_updated_at(sender=None, instance=None, *args, **kwargs): + cache.set('api_updated_notification', datetime.datetime.utcnow()) + + +post_save.connect(receiver=change_api_notification_updated_at, sender=Notification) +post_delete.connect(receiver=change_api_notification_updated_at, sender=Notification) class NotificationListAPI(ListAPIView): @@ -79,7 +92,7 @@ def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) def get_queryset(self): - queryset = Notification.objects.get_unread_notifications_of(self.request.user) + queryset = Notification.objects.get_notifications_of(self.request.user) subscription_type = self.request.query_params.get('subscription_type', None) if subscription_type: queryset = queryset.filter(subscription__content_type__model=subscription_type) diff --git a/zds/settings.py b/zds/settings.py index 6dc9d60729..8b78a22efb 100644 --- a/zds/settings.py +++ b/zds/settings.py @@ -342,7 +342,7 @@ PANDOC_PDF_PARAM = ("--latex-engine=xelatex " "--template={} -s -S -N " "--toc -V documentclass=scrbook -V lang=francais " - "-V mainfont=Merriweather -V monofont=\"Andale Mono\" " + "-V mainfont=Merriweather -V monofont=\"SourceCodePro-Regular\" " "-V fontsize=12pt -V geometry:margin=1in ".format(join("..", "..", "..", "assets", "tex", "template.tex"))) # LOG PATH FOR PANDOC LOGGING diff --git a/zds/tutorialv2/forms.py b/zds/tutorialv2/forms.py index 667fa40444..16e6c38dfc 100644 --- a/zds/tutorialv2/forms.py +++ b/zds/tutorialv2/forms.py @@ -219,7 +219,7 @@ class ContentForm(ContainerForm): label=_(u"Pour m'aider, je cherche un... (non disponible pour les billets)"), queryset=HelpWriting.objects.all(), required=False, - widget=forms.SelectMultiple() + widget=forms.CheckboxSelectMultiple() ) def __init__(self, *args, **kwargs): @@ -265,8 +265,9 @@ def clean(self): self._errors['image'] = self.error_class( [_(u'Votre logo est trop lourd, la limite autorisée est de {} Ko') .format(settings.ZDS_APP['gallery']['image_max_size'] / 1024)]) - if not TagValidator.validate_raw_string(cleaned_data.get("tags")): - self._errors['tags'] = self.error_class([_(u'Vous avez entré un tag trop long.')]) + validator = TagValidator() + if not validator.validate_raw_string(cleaned_data.get("tags")): + self._errors['tags'] = self.error_class(validator.errors) return cleaned_data diff --git a/zds/tutorialv2/tests/tests_views.py b/zds/tutorialv2/tests/tests_views.py index 3f74a8adcd..e563cb5598 100644 --- a/zds/tutorialv2/tests/tests_views.py +++ b/zds/tutorialv2/tests/tests_views.py @@ -18,7 +18,7 @@ from zds.gallery.models import GALLERY_WRITE, UserGallery, Gallery from zds.gallery.models import Image from zds.member.factories import ProfileFactory, StaffProfileFactory, UserFactory -from zds.mp.models import PrivateTopic +from zds.mp.models import PrivateTopic, is_privatetopic_unread from zds.notification.models import TopicAnswerSubscription, ContentReactionAnswerSubscription, \ NewPublicationSubscription, Notification from zds.settings import BASE_DIR @@ -1886,7 +1886,7 @@ def test_validation_workflow(self): self.assertEqual(validation.version, self.tuto_draft.current_version) self.assertEqual(validation.status, 'PENDING') - # ensure that author cannot publish himself + # ensure that author (not staff) cannot access to the validation. result = self.client.post( reverse('validation:reserve', kwargs={'pk': validation.pk}), { @@ -2236,6 +2236,45 @@ def test_validation_workflow(self): self.assertEqual(PrivateTopic.objects.filter(author=self.user_staff).count(), 6) self.assertEqual(PrivateTopic.objects.last().author, self.user_staff) # admin has received another PM + def test_auto_validation(self): + """Test that a staff can validate himself""" + + tuto = PublishableContent.objects.get(pk=self.tuto.pk) + tuto.authors.add(self.user_staff) + tuto.save() + + self.assertEqual(self.client.login(username=self.user_staff.username, password='hostel77'), True) + self.assertEqual(Validation.objects.count(), 0) + + result = self.client.post( + reverse('validation:ask', kwargs={'pk': tuto.pk, 'slug': tuto.slug}), + { + 'text': u'Valide moi ce truc, s\'il te plait', + 'version': self.tuto_draft.current_version + }, + follow=False) + self.assertEqual(result.status_code, 302) + self.assertEqual(Validation.objects.count(), 1) + + validation = Validation.objects.filter(content=tuto).last() + self.assertIsNotNone(validation) + + self.assertTrue(self.user_staff in tuto.authors.all()) + self.assertEqual(0, PrivateTopic.objects.filter(author=self.user_staff, participants=self.user_staff).count()) + result = self.client.post( + reverse('validation:reserve', kwargs={'pk': validation.pk}), + { + 'version': validation.version + }, + follow=False) + self.assertEqual(result.status_code, 302) + self.assertTrue(self.user_staff in tuto.authors.all()) + self.assertEqual(0, PrivateTopic.objects.filter(author=self.user_staff, participants=self.user_staff).count()) + + validation = Validation.objects.filter(content=tuto).last() + self.assertEqual(validation.status, 'PENDING_V') + self.assertEqual(validation.validator, self.user_staff) + def test_delete_while_validating(self): """this test ensure that the validator is warned if the content he is validing is removed""" @@ -5626,6 +5665,11 @@ def test_beta_article_closed_when_published(self): follow=False) self.assertEqual(result.status_code, 302) + # Check that the staff user doesn't have a notification for their reservation and their private topic is read. + self.assertEqual(0, len(Notification.objects.get_unread_notifications_of(self.user_staff))) + last_pm = PrivateTopic.objects.get_private_topics_of_user(self.user_staff.pk).last() + self.assertFalse(is_privatetopic_unread(last_pm, self.user_staff)) + # publish the article result = self.client.post( reverse('validation:accept', kwargs={'pk': validation.pk}), diff --git a/zds/tutorialv2/views/views_validations.py b/zds/tutorialv2/views/views_validations.py index 004dcfb99b..fea2bc3b2d 100644 --- a/zds/tutorialv2/views/views_validations.py +++ b/zds/tutorialv2/views/views_validations.py @@ -286,16 +286,21 @@ def post(self, request, *args, **kwargs): 'url': versioned.get_absolute_url() + '?version=' + validation.version, }) - send_mp( - validation.validator, - validation.content.authors.all(), - _(u"Contenu réservé - {0}").format(validation.content.title), - validation.content.title, - msg, - True, - leave=False, - direct=False - ) + authors = list(validation.content.authors.all()) + if validation.validator in authors: + authors.remove(validation.validator) + if authors.__len__ > 0: + send_mp( + validation.validator, + authors, + _(u"Contenu réservé - {0}").format(validation.content.title), + validation.content.title, + msg, + True, + leave=False, + direct=False, + mark_as_read=True + ) messages.info(request, _(u"Ce contenu a bien été réservé par {0}.").format(request.user.username)) diff --git a/zds/utils/forms.py b/zds/utils/forms.py index 6260c22d6f..57c912c9e8 100644 --- a/zds/utils/forms.py +++ b/zds/utils/forms.py @@ -1,9 +1,16 @@ # coding: utf-8 +import logging from crispy_forms.bootstrap import StrictButton from crispy_forms.layout import Layout, ButtonHolder, Field, Div, HTML from django.utils.translation import ugettext_lazy as _ from zds.utils.models import Tag +from zds.utils.misc import contains_utf8mb4 +# for compat with py3 +try: + assert isinstance("", basestring) +except (NameError, AssertionError): + basestring = str class CommonLayoutEditor(Layout): @@ -65,11 +72,63 @@ class TagValidator(object): """ validate tags """ + def __init__(self): + self.__errors = [] + self.logger = logging.getLogger("zds.utils.forms") + self.__clean = [] - @staticmethod - def validate_raw_string(raw_string): - return TagValidator.validate_string_list(raw_string.split(",")) + def validate_raw_string(self, raw_string): + """ + validate a string composed as ``tag1,tag2``. - @staticmethod - def validate_string_list(string_list): - return all([len(_) <= Tag._meta.get_field("title").max_length for _ in string_list]) + :param raw_string: the string to be validate. If ``None`` this is considered as a empty str + :type raw_string: basestring + :return: ``True`` if ``raw_string`` is fully valid, ``False`` if at least one error appears. See ``self.errors`` + to get all internationalized error. + """ + if raw_string is None or not isinstance(raw_string, basestring): + return self.validate_string_list([]) + return self.validate_string_list(raw_string.split(",")) + + def validate_length(self, tag): + """ + Check the length is in correct range. See ``Tag.label`` max length to have the true upper bound. + + :param tag: the tag lavel to validate + :return: ``True`` if length is valid + """ + if len(tag) > Tag._meta.get_field("title").max_length: + self.errors.append(_(u"Le tag {} est trop long".format(tag))) + self.logger.debug("%s est trop long expected=%d got=%d", tag, + Tag._meta.get_field("title").max_length, len(tag)) + return False + return True + + def validate_string_list(self, string_list): + """ + Same as ``validate_raw_string`` but with a list of tag labels. + + :param string_list: + :return: ``True`` if ``v`` is fully valid, ``False`` if at least one error appears. See ``self.errors`` + to get all internationalized error. + """ + self.__clean = list(filter(self.validate_length, string_list)) + self.__clean = list(filter(self.validate_utf8mb4, self.__clean)) + return len(string_list) == len(self.__clean) + + def validate_utf8mb4(self, tag): + """ + Checks the tag does not contain utf8mb4 chars. + + :param tag: + :return: ``True`` if no utf8mb4 string is found + """ + if contains_utf8mb4(tag): + self.errors.append(_(u"Le tag {} contient des caractères utf8mb4").format(tag)) + self.logger.warn("%s contains utf8mb4 char", tag) + return False + return True + + @property + def errors(self): + return self.__errors diff --git a/zds/utils/forums.py b/zds/utils/forums.py index 899943dd5f..4737a4bffd 100644 --- a/zds/utils/forums.py +++ b/zds/utils/forums.py @@ -10,10 +10,60 @@ from zds.forum.models import Topic, Post from zds.member.views import get_client_ip +from zds.utils.misc import contains_utf8mb4 from zds.utils.mixins import QuoteMixin from zds.utils.models import CommentVote +def get_tag_by_title(title): + """ + Extract tags from title. + In a title, tags can be set this way: + > [Tag 1][Tag 2] There is the real title + Rules to detect tags: + - Tags are enclosed in square brackets. This allows multi-word tags instead of hashtags. + - Tags can embed square brackets: [Tag] is a valid tag and must be written [[Tag]] in the raw title + - All tags must be declared at the beginning of the title. Example: _"Title [tag]"_ will not create a tag. + - Tags and title correctness (example: empty tag/title detection) is **not** checked here + :param title: The raw title + :return: A tuple: (the tag list, the title without the tags). + """ + nb_bracket = 0 + current_tag = u"" + current_title = u"" + tags = [] + continue_parsing_tags = True + original_title = title + + for char in title: + if char == u"[" and nb_bracket == 0 and continue_parsing_tags: + nb_bracket += 1 + elif nb_bracket > 0 and char != u"]" and continue_parsing_tags: + current_tag = current_tag + char + if char == u"[": + nb_bracket += 1 + elif char == u"]" and nb_bracket > 0 and continue_parsing_tags: + nb_bracket -= 1 + if nb_bracket == 0 and current_tag.strip() != u"": + tags.append(current_tag.strip()) + current_tag = u"" + elif current_tag.strip() != u"" and nb_bracket > 0: + current_tag = current_tag + char + + elif (char != u"[" and char.strip() != "") or not continue_parsing_tags: + continue_parsing_tags = False + current_title = current_title + char + + title = current_title + # if we did not succed in parsing the tags + if nb_bracket != 0: + return [], original_title + + tags = filter(lambda tag: not contains_utf8mb4(tag), tags) + + return tags, title.strip() + + def create_topic( request, author, @@ -87,7 +137,7 @@ def get(self, request, *args, **kwargs): # Using the quote button if "cite" in request.GET: - text = self.build_quote(request.GET.get('cite')) + text = self.build_quote(request.GET.get('cite'), request.user) if request.is_ajax(): return HttpResponse(json.dumps({'text': text}), content_type='application/json') diff --git a/zds/utils/misc.py b/zds/utils/misc.py index 740f232ffb..20c63afbaf 100644 --- a/zds/utils/misc.py +++ b/zds/utils/misc.py @@ -45,3 +45,13 @@ def convert_camel_to_underscore(camel_case): """ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_case) return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + +def contains_utf8mb4(s): + """ + This string contains at least one character of more than 3 bytes + """ + if not isinstance(s, unicode): + s = unicode(s, 'utf-8') + re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE) + return s != re_pattern.sub(u'\uFFFD', s) diff --git a/zds/utils/mixins.py b/zds/utils/mixins.py index dbc52dcfe6..7ff1d73d70 100644 --- a/zds/utils/mixins.py +++ b/zds/utils/mixins.py @@ -45,7 +45,7 @@ def get_context_data(self, *args, **kwargs): class QuoteMixin(object): model_quote = None - def build_quote(self, post_pk): + def build_quote(self, post_pk, user): assert self.model_quote is not None, ('Model for the quote cannot be None.') try: @@ -57,6 +57,9 @@ def build_quote(self, post_pk): if isinstance(post_cite, Comment) and not post_cite.is_visible: raise PermissionDenied + if hasattr(post_cite, 'topic') and not post_cite.topic.forum.can_read(user): + raise PermissionDenied + text = '' for line in post_cite.text.splitlines(): text = text + "> " + line + "\n" diff --git a/zds/utils/mps.py b/zds/utils/mps.py index 77efe60d4d..ea71c7743c 100644 --- a/zds/utils/mps.py +++ b/zds/utils/mps.py @@ -6,7 +6,7 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string -from zds.mp.models import PrivateTopic, PrivatePost +from zds.mp.models import PrivateTopic, PrivatePost, mark_read from zds.notification import signals from zds.utils.templatetags.emarkdown import emarkdown @@ -19,7 +19,8 @@ def send_mp( text, send_by_mail=True, leave=True, - direct=False): + direct=False, + mark_as_read=False): """ Send MP at members. Most of the param are obvious, excepted : @@ -41,6 +42,8 @@ def send_mp( n_topic.participants.add(part) topic = send_message_mp(author, n_topic, text, send_by_mail, direct) + if mark_as_read: + mark_read(topic, author) if leave: move = topic.participants.first() diff --git a/zds/utils/templatetags/pluralize_fr.py b/zds/utils/templatetags/pluralize_fr.py new file mode 100644 index 0000000000..5e87f438e8 --- /dev/null +++ b/zds/utils/templatetags/pluralize_fr.py @@ -0,0 +1,32 @@ +# coding: utf-8 + +from django import template + +register = template.Library() + + +@register.filter(is_safe=False) +def pluralize_fr(value, arg='s'): + """ + Based on default pluralize filter : https://github.com/django/django/blob/master/django/template/defaultfilters.py + Support french (-1, 0 and 1 are singular). + """ + if ',' not in arg: + arg = ',' + arg + bits = arg.split(',') + if len(bits) > 2: + return '' + singular_suffix, plural_suffix = bits[:2] + + try: + if not (-2 < float(value) < 2): + return plural_suffix + except ValueError: # Invalid string that's not a number. + pass + except TypeError: # Value isn't a string or a number; maybe it's a list? + try: + if not (-2 < len(value) < 2): + return plural_suffix + except TypeError: # len() of unsized object. + pass + return singular_suffix diff --git a/zds/utils/templatetags/tests/test_top_tags.py b/zds/utils/templatetags/tests/test_top_tags.py index 8943ada859..8ee53fe618 100644 --- a/zds/utils/templatetags/tests/test_top_tags.py +++ b/zds/utils/templatetags/tests/test_top_tags.py @@ -32,41 +32,58 @@ def test_top_tags(self): user = ProfileFactory().user - # Create some topics + # Create 7 topics and give them tags on both public and staff topics + # in random order to make sure it works + # tags are named tag-X-Y, where X is the # times it's assigned to a public topic + # and Y is the total (public + staff) it's been assigned + + topic = TopicFactory(forum=self.forum11, author=user) + topic.add_tags({'tag-3-5'}) + topic = TopicFactory(forum=self.forum11, author=user) - topic.add_tags({'C#'}) + topic.add_tags({'tag-3-5'}) + topic.add_tags({'tag-4-4'}) - topic1 = TopicFactory(forum=self.forum11, author=user) - topic1.add_tags({'C#'}) + topic = TopicFactory(forum=self.forum12, author=self.staff1.user) + topic.add_tags({'tag-0-1'}) + topic.add_tags({'tag-0-2'}) + topic.add_tags({'tag-3-5'}) - topic2 = TopicFactory(forum=self.forum11, author=user) - topic2.add_tags({'C#'}) + topic = TopicFactory(forum=self.forum12, author=self.staff1.user) + topic.add_tags({'tag-0-2'}) + topic.add_tags({'tag-3-5'}) - topic3 = TopicFactory(forum=self.forum11, author=user) - topic3.add_tags({'PHP'}) + topic = TopicFactory(forum=self.forum11, author=user) + topic.add_tags({'tag-4-4'}) + topic.add_tags({'tag-3-5'}) - topic4 = TopicFactory(forum=self.forum11, author=user) - topic4.add_tags({'PHP'}) + topic = TopicFactory(forum=self.forum11, author=user) + topic.add_tags({'tag-4-4'}) - topic5 = TopicFactory(forum=self.forum12, author=user) - topic5.add_tags({'stafftag'}) + topic = TopicFactory(forum=self.forum11, author=user) + topic.add_tags({'tag-4-4'}) - # Now call the function, should be "C#", "PHP" + # Now call the function, should be "tag-4-4", "tag-3-5" top_tags = top_categories(user).get('tags') - # Assert - self.assertEqual(top_tags[0].title, 'c#') - self.assertEqual(top_tags[1].title, 'php') + # tag-X-Y : X should be decreasing + self.assertEqual(top_tags[0].title, 'tag-4-4') + self.assertEqual(top_tags[1].title, 'tag-3-5') self.assertEqual(len(top_tags), 2) # Admin should see theirs specifics tags - top_tags_for_staff = top_categories(self.staff1.user).get('tags') - self.assertEqual(top_tags_for_staff[2].title, 'stafftag') + top_tags = top_categories(self.staff1.user).get('tags') + # tag-X-Y : Y should be decreasing + self.assertEqual(top_tags[0].title, 'tag-3-5') + self.assertEqual(top_tags[1].title, 'tag-4-4') + self.assertEqual(top_tags[2].title, 'tag-0-2') + self.assertEqual(top_tags[3].title, 'tag-0-1') + self.assertEqual(len(top_tags), 4) # Now we want to exclude a tag - settings.ZDS_APP['forum']['top_tag_exclu'] = {'php'} - top_tags = top_categories(user).get('tags') + settings.ZDS_APP['forum']['top_tag_exclu'] = {'tag-4-4'} - # Assert that we should only have one tags - self.assertEqual(top_tags[0].title, 'c#') + # User only sees the only 'public' tag left + top_tags = top_categories(user).get('tags') + self.assertEqual(top_tags[0].title, 'tag-3-5') self.assertEqual(len(top_tags), 1) diff --git a/zds/utils/templatetags/tests/tests_pluralize_fr.py b/zds/utils/templatetags/tests/tests_pluralize_fr.py new file mode 100644 index 0000000000..a9e6185f80 --- /dev/null +++ b/zds/utils/templatetags/tests/tests_pluralize_fr.py @@ -0,0 +1,42 @@ +# coding: utf-8 + +# Based on default pluralize filter tests : +# https://github.com/django/django/blob/master/tests/template_tests/filter_tests/test_pluralize.py + +from decimal import Decimal + +from django.test import SimpleTestCase + +from zds.utils.templatetags.pluralize_fr import pluralize_fr + + +class FunctionTests(SimpleTestCase): + + def test_integers(self): + self.assertEqual(pluralize_fr(1), '') + self.assertEqual(pluralize_fr(0), '') + self.assertEqual(pluralize_fr(2), 's') + + def test_floats(self): + self.assertEqual(pluralize_fr(0.5), '') + self.assertEqual(pluralize_fr(1.5), '') + self.assertEqual(pluralize_fr(2.5), 's') + + def test_decimals(self): + self.assertEqual(pluralize_fr(Decimal(1)), '') + self.assertEqual(pluralize_fr(Decimal(0)), '') + self.assertEqual(pluralize_fr(Decimal(2)), 's') + + def test_lists(self): + self.assertEqual(pluralize_fr([1]), '') + self.assertEqual(pluralize_fr([]), '') + self.assertEqual(pluralize_fr([1, 2, 3]), 's') + + def test_suffixes(self): + self.assertEqual(pluralize_fr(1, 'es'), '') + self.assertEqual(pluralize_fr(0, 'es'), '') + self.assertEqual(pluralize_fr(2, 'es'), 'es') + self.assertEqual(pluralize_fr(1, 'y,ies'), 'y') + self.assertEqual(pluralize_fr(0, 'y,ies'), 'y') + self.assertEqual(pluralize_fr(2, 'y,ies'), 'ies') + self.assertEqual(pluralize_fr(0, 'y,ies,error'), '') diff --git a/zds/utils/templatetags/topbar.py b/zds/utils/templatetags/topbar.py index 1df9fc958c..bd34a8c976 100644 --- a/zds/utils/templatetags/topbar.py +++ b/zds/utils/templatetags/topbar.py @@ -27,14 +27,23 @@ def top_categories(user): forums = list(forums_pub) cats = defaultdict(list) + forums_pk = [] for forum in forums: - cats[forum.category.title].append(forum) + forums_pk.append(forum.pk) + cats[forum.category.position].append(forum) + + topbar_cats = [] + sorted_cats = sorted(cats) + for cat in sorted_cats: + forums = cats[cat] + title = forums[0].category.title + topbar_cats.append((title, forums)) tags_by_popularity = list( Topic.objects .values('tags__pk', 'tags__title') .distinct() - .filter(forum__in=forums, tags__isnull=False) + .filter(tags__isnull=False, forum__in=forums_pk) .annotate(nb_tags=Count('tags')) .order_by('-nb_tags') [:max_tags + len(settings.ZDS_APP['forum']['top_tag_exclu'])]) @@ -45,7 +54,7 @@ def top_categories(user): tags = Tag.objects.filter(pk__in=tags_not_excluded) tags = sorted(tags, key=lambda tag: tags_not_excluded.index(tag.pk)) - return {'tags': tags, 'categories': cats} + return {'tags': tags, 'categories': topbar_cats} @register.filter('top_categories_content') diff --git a/zds/utils/tests/test_misc.py b/zds/utils/tests/test_misc.py new file mode 100644 index 0000000000..1f980fc700 --- /dev/null +++ b/zds/utils/tests/test_misc.py @@ -0,0 +1,14 @@ +# coding: utf-8 +from django.test import TestCase + +from zds.utils.misc import contains_utf8mb4 + + +class Misc(TestCase): + def test_utf8mb4(self): + self.assertFalse(contains_utf8mb4('abc')) + self.assertFalse(contains_utf8mb4(u'abc')) + self.assertFalse(contains_utf8mb4('abc€')) + self.assertFalse(contains_utf8mb4(u'abc€')) + self.assertTrue(contains_utf8mb4('a🐙tbc€')) + self.assertTrue(contains_utf8mb4(u'a🐙tbc€')) diff --git a/zds/utils/tests/tests_models.py b/zds/utils/tests/tests_models.py index ac917c2a0a..3c768af76f 100644 --- a/zds/utils/tests/tests_models.py +++ b/zds/utils/tests/tests_models.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.db import IntegrityError, transaction +from zds.utils.forms import TagValidator from zds.utils.models import Tag @@ -58,3 +59,18 @@ def insert_duplicated_tags(tags): self.assertIn('azerty', all_slugs) self.assertIn('qwerty', all_slugs) self.assertIn('another-tag', all_slugs) + + def test_validator_with_correct_tags(self): + tag = Tag(title="a test") + tag.save() + validator = TagValidator() + self.assertEqual(validator.validate_raw_string(None), True) + self.assertEqual(validator.validate_raw_string(tag.title), True) + self.assertEqual(validator.errors, []) + + def test_validator_with_utf8mb4(self): + + raw_string = u"🐙☢,bla" + validator = TagValidator() + self.assertFalse(validator.validate_raw_string(raw_string)) + self.assertEqual(1, len(validator.errors))