Commit be0505a4 authored by Tangui's avatar Tangui

Merge branch 'master' of git.regardscitoyens.org:regardscitoyens/irfm

parents c050adba 78777808
......@@ -17,6 +17,7 @@ from .models import db
from .tools.files import generer_demandes as generer_demandes_
from .tools.mails import (envoyer_emails as envoyer_emails_,
envoyer_relances as envoyer_relances_,
mailing_lists as mailing_lists_)
from .tools.procedure import fix_procedure as fix_procedure_
from .tools.text import hash_password
......@@ -60,6 +61,14 @@ def envoyer_emails(envoyer=False):
print('Aucun parlementaire sans adresse mail :)')
@manager.command
@manager.option('--envoyer', action='store_true')
def envoyer_relances(envoyer=False):
"""Envoie des e-mails de relance"""
app.config.update(SQLALCHEMY_ECHO=False)
envoyer_relances_(app, envoyer)
@manager.command
def mailing_lists():
"""Affiche les abonnés aux mailing lists"""
......
......@@ -9,6 +9,10 @@ from ..models import Action, Parlementaire, db
from ..models.constants import ETAPE_ENVOYE
class SuiviInvalide(Exception):
pass
class LaPosteImporter(BaseImporter):
URL = 'http://www.part.csuivi.courrier.laposte.fr/suivi/index?id={}'
......@@ -35,8 +39,11 @@ class LaPosteImporter(BaseImporter):
return None
ident = ident[0]
if ident.text.strip().startswith('Aucun '):
idtxt = ident.text.strip()
if idtxt.startswith('Aucun '):
return None
elif idtxt.startswith('L\'identifiant saisi'):
raise SuiviInvalide()
produit = self._next_el_sibling(ident)
date = self._next_el_sibling(produit)
......@@ -50,8 +57,12 @@ class LaPosteImporter(BaseImporter):
def import_suivi(self, suivi):
if suivi not in self.cache:
try:
statut = self._import_suivi(suivi)
self.info('SUIVI %s => %s' % (suivi, statut))
except SuiviInvalide:
self.error('INVALIDE %s' % suivi)
statut = 'Suivi invalide !'
self.cache[suivi] = statut
return self.cache[suivi]
......
......@@ -15,28 +15,14 @@ while _m < 1:
DEBUT_RELEVES = datetime.date(_y, _m, DEBUT_ACTION.day)
# Délais pour la relance de citoyens en jours
DELAI_RELANCE = 7
DELAI_REPONSE = 2
#
# Lors de la modification de ces énumérations, penser à créer une migration DB
# pour mettre à jour les types ENUM correspondants en base de données.
#
# Etapes
CHAMBRES = {
'AN': 'Assemblée nationale',
'SEN': 'Sénat',
}
SEXES = {
'F': 'Femme',
'H': 'Homme',
}
#
# Lors de la modification de cette énumération, relancer l'import des étapes.
# L'ordre est utilisé comme clé primaire lors de cet import.
#
ETAPE_DOCUMENT = -30
ETAPE_DOC_PUBLIE = -31
ETAPE_DOC_MASQUE = -30
ETAPE_COM_PUBLIE = -21
ETAPE_COM_A_MODERER = -20
ETAPE_COURRIEL = -10
......@@ -50,12 +36,23 @@ ETAPE_REPONSE_NEGATIVE = 50
ETAPES = [
{
'ordre': ETAPE_DOCUMENT,
'ordre': ETAPE_DOC_PUBLIE,
'label': 'Document',
'description': """
Un document nous a été transmis par le parlementaire.
""",
'couleur': '#88dd88',
'couleur': '#33aa33',
'icone': 'paperclip',
'hidden': False,
'alerte': False,
},
{
'ordre': ETAPE_DOC_MASQUE,
'label': 'Document (non publié)',
'description': """
Un document nous a été transmis par le parlementaire.
""",
'couleur': '#ccaa66',
'icone': 'paperclip',
'hidden': True,
'alerte': False,
......@@ -182,3 +179,19 @@ ETAPES = [
]
ETAPES_BY_ORDRE = {e['ordre']: e for e in ETAPES}
#
# Lors de la modification de ces énumérations, penser à créer une migration DB
# pour mettre à jour les types ENUM correspondants en base de données.
#
CHAMBRES = {
'AN': 'Assemblée nationale',
'SEN': 'Sénat',
}
SEXES = {
'F': 'Femme',
'H': 'Homme',
}
# -*- coding: utf-8 -*-
from sqlalchemy.sql.expression import case, func
from .constants import (ETAPE_A_CONFIRMER, ETAPE_A_ENVOYER, ETAPE_ENVOYE,
ETAPE_NA, ETAPES)
from .database import db
from .parlementaire import Parlementaire
from .procedure import Action
def etat_courriers():
"""
Renvoie les données pour constituer un histogramme des états des courriers.
"""
_categories = [
{'etats': [''], 'label': 'Inconnu'},
{'etats': ['Pris en charge', 'En cours de traitement'],
'label': 'Pris en charge'},
{'etats': ['Pli présenté', 'En attente de seconde présentation'],
'label': 'Présenté'},
{'etats': ['Attend d\'être retiré au guichet'], 'label': 'Au guichet'},
{'etats': ['Distribué'], 'label': 'Distribué'}
]
# Extrait "Distribué" de "1X23456: Distribué (01/02/2017)"
expr = func.split_part(
func.split_part(
Action.suivi, ':', 2),
' (', 1
)
data = {item.etat: item.nb
for item in db.session.query(expr.label('etat'),
func.count(1).label('nb'))
.filter(Action.etape == ETAPE_ENVOYE)
.group_by(expr)
.all()}
return [(c['label'], sum([data.get(e, 0) for e in c['etats']]))
for c in _categories]
def par_departement():
"""
Renvoie chaque département avec le nombre total de parlementaires, le
nombre à chaque étape, le nombre >= pris en charge, le nombre >= envoyé
"""
# Comptage des parlementaires par département...
dept_qs = db.session \
.query(Parlementaire.num_deptmt,
func.count(Parlementaire.id).label('total'))
# ...et par étape
dept_qs = dept_qs.add_columns(*[
func.sum(case([(Parlementaire.etape == e['ordre'], 1)], else_=0))
.label('nb_etape_%s' % e['ordre'])
for e in ETAPES
])
# ...et qui sont dans une étape >= pris en charge
dept_qs = dept_qs.add_columns(
func.sum(case([(Parlementaire.etape >= ETAPE_A_CONFIRMER, 1)],
else_=0)).label('nb_prisencharge'),
func.sum(case([(Parlementaire.etape >= ETAPE_ENVOYE, 1)],
else_=0)).label('nb_envoyes')
)
return dept_qs.group_by(Parlementaire.num_deptmt) \
.order_by(Parlementaire.num_deptmt) \
.all()
def par_etape():
"""
Renvoie les étapes avec le nombre de parlementaires par étape
"""
count = func.count(Parlementaire.id)
return db.session.query(Parlementaire.etape, count.label('nb')) \
.filter(Parlementaire.etape > ETAPE_NA) \
.group_by(Parlementaire.etape) \
.order_by(Parlementaire.etape) \
.having(count > 0) \
.all()
def random_parl():
"""
Renvoie un parlementaire au hasard parmi ceux à l'état "À envoyer", ou si
aucun n'est disponible parmi ceux concernés par l'opération
"""
parl = Parlementaire.query \
.filter(Parlementaire.etape == ETAPE_A_ENVOYER) \
.order_by(func.random()) \
.first()
if not parl:
parl = Parlementaire.query \
.filter(Parlementaire.etape > ETAPE_NA) \
.order_by(func.random()) \
.first()
return parl
......@@ -10,7 +10,8 @@ from sqlalchemy.orm import joinedload
from ..models import Action, Parlementaire, User, db
from ..models.constants import (ETAPE_A_CONFIRMER, ETAPE_A_ENVOYER,
ETAPES_BY_ORDRE, ETAPE_COM_A_MODERER,
ETAPE_COM_PUBLIE, ETAPE_DOCUMENT)
ETAPE_COM_PUBLIE, ETAPE_DOC_MASQUE,
ETAPE_DOC_PUBLIE)
from ..tools.files import EXTENSIONS, handle_upload
from ..tools.mails import envoyer_alerte
......@@ -103,12 +104,20 @@ def setup_routes(app):
@require_admin
def admin_publish(id):
action = Action.query \
.filter(Action.etape == ETAPE_COM_A_MODERER) \
.filter(Action.etape.in_([ETAPE_COM_A_MODERER,
ETAPE_DOC_MASQUE,
ETAPE_DOC_PUBLIE])) \
.filter(Action.id == id) \
.first()
if action:
if action.etape == ETAPE_COM_A_MODERER:
action.etape = ETAPE_COM_PUBLIE
elif action.etape == ETAPE_DOC_PUBLIE:
action.etape = ETAPE_DOC_MASQUE
elif action.etape == ETAPE_DOC_MASQUE:
action.etape = ETAPE_DOC_PUBLIE
db.session.commit()
return redirect_back()
......@@ -147,7 +156,7 @@ def setup_routes(app):
return redirect_back(error=msg,
fallback=url_for('parlementaire', id=id_parl))
if etape == ETAPE_DOCUMENT:
if etape in (ETAPE_DOC_MASQUE, ETAPE_DOC_PUBLIE):
prefix = 'document'
else:
prefix = 'etape-%s' % etape
......
......@@ -4,12 +4,13 @@ from datetime import datetime
from flask import session, url_for
from ..models import Action, Parlementaire
from ..models import Action, Parlementaire, User
from ..models.constants import (CHAMBRES, ETAPES, ETAPES_BY_ORDRE,
ETAPE_AR_RECU, ETAPE_A_CONFIRMER,
ETAPE_A_ENVOYER, ETAPE_COM_A_MODERER,
ETAPE_COM_PUBLIE, ETAPE_COURRIEL, ETAPE_ENVOYE,
ETAPE_NA, ETAPE_REPONSE_NEGATIVE,
ETAPE_COM_PUBLIE, ETAPE_COURRIEL,
ETAPE_DOC_MASQUE, ETAPE_DOC_PUBLIE,
ETAPE_ENVOYE, ETAPE_NA, ETAPE_REPONSE_NEGATIVE,
ETAPE_REPONSE_POSITIVE)
......@@ -108,25 +109,29 @@ def setup(app):
.filter(Parlementaire.etape == ETAPE_A_CONFIRMER).count()
if nb_aconfirmer > 0:
badge = ' <span class="badge">%s</span>' % nb_aconfirmer
menu += [
{
'url': url_for('admin_en_attente'),
'label': '<span class="admin">À confirmer (%s)</span>'
% nb_aconfirmer,
'label': '<span class="admin">À confirmer</span> %s'
% badge,
'endpoint': 'admin_en_attente'
}
]
nb_moderer = Action.query \
.join(Action.user) \
.filter(Action.etape == ETAPE_COM_A_MODERER) \
.filter(User.nick != '!rc') \
.count()
if nb_moderer > 0:
badge = ' <span class="badge">%s</span>' % nb_moderer
menu += [
{
'url': url_for('admin_commentaires'),
'label': '<span class="admin">À modérer (%s)</span>'
% nb_moderer,
'label': '<span class="admin">À modérer</span> %s'
% badge,
'endpoint': 'admin_commentaires'
}
]
......@@ -141,6 +146,8 @@ def setup(app):
'ordres': {
'ETAPE_COM_PUBLIE': ETAPE_COM_PUBLIE,
'ETAPE_COM_A_MODERER': ETAPE_COM_A_MODERER,
'ETAPE_DOC_MASQUE': ETAPE_DOC_MASQUE,
'ETAPE_DOC_PUBLIE': ETAPE_DOC_PUBLIE,
'ETAPE_COURRIEL': ETAPE_COURRIEL,
'ETAPE_NA': ETAPE_NA,
'ETAPE_A_ENVOYER': ETAPE_A_ENVOYER,
......
......@@ -20,8 +20,8 @@ def setup_routes(app):
if not os.path.exists(uploads_root):
os.mkdir(uploads_root)
@app.route('/parlementaire/<id>/demande/<mode>', endpoint='demande_pdf')
def demande_pdf(id, mode='download'):
@app.route('/parlementaire/<id>/demande', endpoint='demande_pdf')
def demande_pdf(id):
parl = Parlementaire.query.filter_by(id=id).first()
if not parl:
......@@ -30,6 +30,16 @@ def setup_routes(app):
filename = generer_demande(parl, files_root)
return redirect(url_for('get_file', filename=filename))
@app.route('/parlementaire/<id>/demande_png', endpoint='demande_png')
def demande_png(id):
parl = Parlementaire.query.filter_by(id=id).first()
if not parl:
return not_found()
filename = '%s.png' % generer_demande(parl, files_root)[:-4]
return redirect(url_for('get_file', filename=filename))
@app.route('/parlementaire/<id>/preuve-envoi', endpoint='preuve_envoi')
def preuve_envoi(id):
act = Action.query.filter(Action.etape == ETAPE_ENVOYE) \
......
......@@ -2,73 +2,49 @@
from flask import render_template
from sqlalchemy.sql.expression import case, func
from ..models import Parlementaire, db
from ..models.constants import (ETAPES, ETAPES_BY_ORDRE, ETAPE_A_CONFIRMER,
ETAPE_A_ENVOYER, ETAPE_ENVOYE, ETAPE_NA)
from ..models.constants import ETAPES_BY_ORDRE
from ..models.queries import (etat_courriers, par_etape, par_departement,
random_parl)
def setup_routes(app):
@app.route('/', endpoint='home')
def home():
# Un parlementaire à l'étape "à envoyer" au hasard
parl = Parlementaire.query \
.filter(Parlementaire.etape == ETAPE_A_ENVOYER) \
.order_by(func.random()) \
.first()
# Toutes les étapes avec le nombre de parlementaires à cette étape
etapes_qs = db.session \
.query(Parlementaire.etape,
func.count(Parlementaire.id).label('nb')) \
.filter(Parlementaire.etape > ETAPE_NA) \
.group_by(Parlementaire.etape) \
.order_by(Parlementaire.etape) \
.all()
# Données camembert
# Comptage des parlementaires par département...
dept_qs = db.session \
.query(Parlementaire.num_deptmt,
func.count(Parlementaire.id).label('total'))
etapes_qs = par_etape()
# ...et par étape
dept_qs = dept_qs.add_columns(*[
func.sum(case([(Parlementaire.etape == e['ordre'], 1)], else_=0))
.label('nb_etape_%s' % e['ordre'])
for e in ETAPES
])
def each_etape(getter):
return [getter(e) for e in etapes_qs]
# ...et qui sont dans une étape >= pris en charge
dept_qs = dept_qs.add_columns(
func.sum(case([(Parlementaire.etape >= ETAPE_A_CONFIRMER, 1)],
else_=0)).label('nb_prisencharge'),
func.sum(case([(Parlementaire.etape >= ETAPE_ENVOYE, 1)],
else_=0)).label('nb_envoyes')
)
def key_each_etape(key):
return each_etape(lambda e: ETAPES_BY_ORDRE[e.etape][key])
dept_qs = dept_qs.group_by(Parlementaire.num_deptmt) \
.order_by(Parlementaire.num_deptmt) \
.all()
etapes_data = {
'labels': key_each_etape('label'),
'datasets': [{
'data': each_etape(lambda e: e.nb),
'backgroundColor': key_each_etape('couleur'),
'hoverBackgroundColor': key_each_etape('couleur'),
'borderWidth': 0
}]
}
def for_nz(getter):
return [getter(e) for e in etapes_qs if e.nb > 0]
# Données histogramme
def key_for_nz(key):
return for_nz(lambda e: ETAPES_BY_ORDRE[e.etape][key])
etats = etat_courriers()
histo_data = {
'labels': [etat for etat, nb in etats],
'datasets': [{
'data': [nb for etat, nb in etats]
}]
}
return render_template(
'index.html.j2',
parlementaire=parl,
etapes_data={
'labels': key_for_nz('label'),
'datasets': [{
'data': for_nz(lambda e: e.nb),
'backgroundColor': key_for_nz('couleur'),
'hoverBackgroundColor': key_for_nz('couleur'),
'borderWidth': for_nz(lambda e: 0)
}]
},
departements=dept_qs
parlementaire=random_parl(),
etapes_data=etapes_data,
histo_data=histo_data,
departements=par_departement()
)
......@@ -8,15 +8,17 @@ from flask import (flash, redirect, render_template, request, session, url_for)
from flask_mail import Mail, Message
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
from ..models import Action, Parlementaire, User, db
from ..models.constants import (ETAPE_A_CONFIRMER, ETAPE_A_ENVOYER,
ETAPE_COM_A_MODERER, ETAPE_COM_PUBLIE,
ETAPE_ENVOYE, ETAPE_NA)
ETAPE_ENVOYE)
from ..models.queries import random_parl
from ..tools.files import generer_demande, handle_upload
from ..tools.routing import not_found, redirect_back, remote_addr, require_user
from ..tools.text import check_suivi, slugify
from ..tools.routing import (can_login_from_token, not_found, redirect_back,
remote_addr, require_user)
from ..tools.text import check_suivi, create_usertoken as token, slugify
def pris_en_charge(parl, force=False):
......@@ -46,17 +48,7 @@ def setup_routes(app):
@app.route('/hasard', endpoint='hasard')
def hasard():
parl = Parlementaire.query \
.filter(Parlementaire.etape == ETAPE_A_ENVOYER) \
.order_by(func.random()) \
.first()
if not parl:
parl = Parlementaire.query \
.filter(Parlementaire.etape > ETAPE_NA) \
.order_by(func.random()) \
.first()
return redirect(url_for('parlementaire', id=parl.id))
return redirect(url_for('parlementaire', id=random_parl().id))
@app.route('/parlementaires', endpoint='parlementaires')
def parlementaires():
......@@ -71,6 +63,7 @@ def setup_routes(app):
)
@app.route('/parlementaires/<id>', endpoint='parlementaire')
@can_login_from_token
def parlementaire(id):
parl = Parlementaire.query \
.filter_by(id=id) \
......@@ -143,6 +136,8 @@ def setup_routes(app):
subject = 'Transparence IRFM - Envoi d\'une demande de documents'
body = render_template('courriers/mail_prise_en_charge.txt.j2',
token=token(session['user']['id'],
app.config['SECRET_KEY']),
parlementaire=parl)
msg = Message(subject=subject, body=body,
sender=('Regards Citoyens',
......@@ -152,6 +147,9 @@ def setup_routes(app):
with open(os.path.join(files_root, filename), 'rb') as f:
msg.attach(filename, 'application/pdf', f.read())
if app.config['MAIL_SUPPRESS_SEND']:
print(msg)
mail.send(msg)
return redirect(url_for('parlementaire', id=id))
......@@ -213,7 +211,8 @@ def setup_routes(app):
return redirect_back(error=msg,
fallback=url_for('parlementaire', id=id))
if not check_suivi(request.form['suivi']):
suivi = check_suivi(request.form['suivi'])
if not suivi:
msg = 'Veuillez indiquer un numéro de suivi valide'
return redirect_back(error=msg,
fallback=url_for('parlementaire', id=id))
......@@ -236,7 +235,7 @@ def setup_routes(app):
parlementaire=parl,
etape=parl.etape,
attachment=filename,
suivi=request.form['suivi'].upper()
suivi=suivi
)
db.session.add(action)
......
......@@ -8,7 +8,8 @@ from ..models import Action, Parlementaire, User, db
from ..models.constants import ETAPE_A_CONFIRMER, ETAPE_ENVOYE
from ..tools.routing import not_found, redirect_back, require_user
from ..tools.text import check_email, check_password, sanitize_hard
from ..tools.text import (check_email, check_password, is_safe_url,
sanitize_hard)
def setup_routes(app):
......@@ -50,23 +51,23 @@ def setup_routes(app):
if nick != request.form['nick']:
msg = 'Seuls les caractères suivants sont autorisés: ' \
'a-z 0-9 _ - @ . '
return redirect_back(error=msg)
return redirect_back(login_error=msg)
if not len(nick):
msg = 'Veuillez saisir un pseudonyme !'
return redirect_back(error=msg)
return redirect_back(login_error=msg)
email = request.form['email'].strip()
if not check_email(email):
msg = 'Veuillez saisir une adresse e-mail valide pour assurer ' \
'le suivi de l\'envoi des demandes !'
return redirect_back(error=msg)
return redirect_back(login_error=msg)
user = User.query.filter(User.nick == nick).first()
if user and user.email != email:
msg = 'L\'adresse e-mail que vous avez saisie n\'est pas la bonne.'
return redirect_back(error=msg)
return redirect_back(login_error=msg)
if not user:
user = User(nick=nick, email=email, admin=False)
......@@ -87,13 +88,15 @@ def setup_routes(app):
return redirect(url_for('envoi',
id=request.form['prendre_en_charge']))
if 'next' in request.form and is_safe_url(request.form['next']):
return redirect(request.form['next'])
return redirect_back()
@app.route('/logout')
def logout():
session.pop('user', None)
return redirect_back()
return redirect(url_for('home'))
@app.route('/profil', endpoint='profil', methods=['GET', 'POST'])
@require_user
......
......@@ -123,24 +123,28 @@ th.col-nobreak {
#pie-container {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
#pie-container #pie,
#pie-container #pie-legend {
flex-grow: 0;
flex-shrink: 0;
}
#pie-container #pie {
text-align: center;
flex-basis: 70%;
flex-shrink: 1;
flex-basis: 65%;
padding-right: 5%;
position: relative;
}
#pie-container #pie canvas {
margin: 0 auto 0 auto;
width: 100%;
height: 60%;
}
#pie-container #pie-legend {
flex-shrink: 0;
flex-basis: 30%;
}
......
......@@ -90,6 +90,13 @@
{% else %}
<li>
<form class="form-horizontal login-form" method="POST" action="{{ url_for('login') }}">
{% for cat, msg in get_flashed_messages(with_categories=True, category_filter=['login_error', 'login_next']) %}
{% if cat == 'login_error' %}
<div class="alert alert-danger login-error">{{ msg }}</div>
{% elif cat == 'login_next' %}
<input name="next" type="hidden" value="{{ msg }}">
{% endif %}
{% endfor %}
<input name="nick" type="text" class="form-control" placeholder="Pseudo">
<input name="email" type="text" class="form-control" placeholder="Adresse e-mail">
<small>Votre adresse e-mail ne sera pas publiée.</small><br><br>
......@@ -109,7 +116,7 @@
{% for cat, message in get_flashed_messages(with_categories=True) %}
{% if cat == 'success' %}
<div class="alert alert-success" role="alert">{{ message }}</div>
{% else %}
{% elif cat not in ('login_error', 'login_next') %}
<div class="alert alert-danger" role="alert">{{ message }}</div>
{% endif %}
{% endfor %}
......@@ -196,6 +203,11 @@
});
$('[data-toggle="tooltip"]').tooltip()
if ($('.login-error').length) {
$('.login .dropdown-toggle').dropdown('toggle');
$('.login [name="nick"]').focus();
}
});
</script>
</body>
......@@ -41,8 +41,14 @@
{% if action.suivi and action.etape == ordres.ETAPE_ENVOYE %}
<a href="http://www.part.csuivi.courrier.laposte.fr/suivi/index?id={{ action.suivi }}" target="_blank">{{ action.suivi }}</a>
{% elif action.suivi %}
{% if action.user and action.user.nick == '!rc' %}
{% filter markdown -%}
{{ action.suivi }}
{%- endfilter %}
{% else %}
{{ action.suivi|e }}
{% endif %}
{% endif %}
</td>
<td class="col-right">
<a class="btn btn-primary btn-sm" href="{{ url_for('admin_publish', id=action.id) }}" title="Publier" data-toggle="tooltip"><i class="fa fa-check"></i></a>
......
......@@ -44,22 +44,32 @@
{% if action.suivi and action.etape == ordres.ETAPE_ENVOYE %}
{{ action.suivi|suivi_laposte }}
{% elif action.suivi %}
{% if action.user and action.user.nick == '!rc' %}
{% filter markdown -%}
{{ action.suivi }}
{%- endfilter %}
{% else %}
{{ action.suivi|e }}
{% endif %}
{% endif %}
</td>
<td>
<td class="co