Commit 9332a554747a59bbb348f0f82bad0a75ca89bd7f

Authored by hitier
2 parents ef44d9ee 38637852

File logging and route access control

@@ -11,3 +11,4 @@ _static @@ -11,3 +11,4 @@ _static
11 _templates 11 _templates
12 *db 12 *db
13 db_config.py 13 db_config.py
  14 +logs
@@ -24,6 +24,13 @@ or major refactoring improvments. @@ -24,6 +24,13 @@ or major refactoring improvments.
24 24
25 ## Unreleased 25 ## Unreleased
26 26
  27 +## [0.2.pre-6] - 2021-03-29 - Role Controle, and per site configuration
  28 +### New
  29 +Route access control by User role
  30 +Per site configuration
  31 +Route error handling
  32 +Logging to log file
  33 +
27 ## [0.2.pre-5] - 2021-03-19 - More Deploy facilities 34 ## [0.2.pre-5] - 2021-03-19 - More Deploy facilities
28 ### Changed 35 ### Changed
29 More documentation 36 More documentation
@@ -22,6 +22,8 @@ @@ -22,6 +22,8 @@
22 ### Configurer l'application 22 ### Configurer l'application
23 23
24 # D'abord les accés base de donnée 24 # D'abord les accés base de donnée
  25 + cp resources/pdc_config.py .
  26 + $(EDITOR) pdc_config.py
25 cp resources/db_config.py . 27 cp resources/db_config.py .
26 $(EDITOR) db_config.py 28 $(EDITOR) db_config.py
27 29
@@ -64,6 +66,7 @@ Utiliser l'outil de ligne de commande fourni avec l'application. @@ -64,6 +66,7 @@ Utiliser l'outil de ligne de commande fourni avec l'application.
64 Les fichiers concernés: 66 Les fichiers concernés:
65 67
66 - pdc_web.wsgi 68 - pdc_web.wsgi
  69 +- pdc_config.py
67 - db_config.py 70 - db_config.py
68 71
69 La procédure: 72 La procédure:
@@ -82,7 +85,7 @@ La procédure: @@ -82,7 +85,7 @@ La procédure:
82 python -m venv venv 85 python -m venv venv
83 source venv/bin/activate 86 source venv/bin/activate
84 pip install -r requirements.txt 87 pip install -r requirements.txt
85 - $(EDITOR) db_config.py .flaskenv 88 + $(EDITOR) pdc_config.py db_config.py .flaskenv
86 89
87 # configurer le serveur web 90 # configurer le serveur web
88 cp ./resources/apache2-virtual-host.conf /etc/apache2/sites-available/pdc-web.conf 91 cp ./resources/apache2-virtual-host.conf /etc/apache2/sites-available/pdc-web.conf
@@ -141,5 +144,3 @@ Normalement, cette configuration permet de lire les variables positionées dans @@ -141,5 +144,3 @@ Normalement, cette configuration permet de lire les variables positionées dans
141 144
142 145
143 Ainsi fait, exécutez votre projet depuis pycharm et testez. 146 Ainsi fait, exécutez votre projet depuis pycharm et testez.
144 -flask db update  
145 -db_config  
1 -0.2.pre-5 1 +0.2.pre-6
app/__init__.py
@@ -4,7 +4,18 @@ import sys @@ -4,7 +4,18 @@ import sys
4 from flask import Flask 4 from flask import Flask
5 from flask_login import LoginManager 5 from flask_login import LoginManager
6 6
7 -from app.models import db, User 7 +import logging
  8 +from logging.handlers import RotatingFileHandler
  9 +from flask.logging import default_handler
  10 +
  11 +from app.models import db
  12 +from app.auth.models import User
  13 +
  14 +from dotenv import load_dotenv
  15 +
  16 +app_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), '..')
  17 +env_file = os.path.join(app_dir, ".flaskenv")
  18 +load_dotenv(env_file, verbose=True)
8 19
9 login_manager = LoginManager() 20 login_manager = LoginManager()
10 21
@@ -17,12 +28,13 @@ def load_user(user_id): @@ -17,12 +28,13 @@ def load_user(user_id):
17 return User.query.get(int(user_id)) 28 return User.query.get(int(user_id))
18 29
19 30
  31 +# TODO: move the following into create_app()
  32 +# and use app.logger system
20 # Please, set a config file on top project dir 33 # Please, set a config file on top project dir
21 # see in ../pdc_config.py 34 # see in ../pdc_config.py
22 try: 35 try:
23 from pdc_config import Config, ProdConfig, DevConfig, TestConfig 36 from pdc_config import Config, ProdConfig, DevConfig, TestConfig
24 except ImportError: 37 except ImportError:
25 - # TODO: use logging system  
26 print("Please set a pdc_config.py file in you PYTHON_PATH") 38 print("Please set a pdc_config.py file in you PYTHON_PATH")
27 print("See INSTALL.md for more info") 39 print("See INSTALL.md for more info")
28 sys.exit(-1) 40 sys.exit(-1)
@@ -56,6 +68,25 @@ def create_app(config_class=None): @@ -56,6 +68,25 @@ def create_app(config_class=None):
56 app = Flask(__name__) 68 app = Flask(__name__)
57 app.config.from_object(config_class) 69 app.config.from_object(config_class)
58 70
  71 + # Log to stdout or to file
  72 + #
  73 + app.logger.removeHandler(default_handler)
  74 + if app.debug or app.testing or app.config['LOG_TO_STDOUT']:
  75 + log_handler = logging.StreamHandler()
  76 + else:
  77 + logs_file = app.config['PDC_LOGS_FILE']
  78 + logs_dir = os.path.dirname(logs_file)
  79 + if not os.path.exists(logs_dir):
  80 + os.mkdir(logs_dir)
  81 + log_handler = RotatingFileHandler(logs_file, maxBytes=10240, backupCount=10)
  82 +
  83 + log_handler.setFormatter(logging.Formatter(
  84 + '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
  85 + app.logger.addHandler(log_handler)
  86 +
  87 + app.logger.setLevel(app.config['PDC_LOGS_LEVEL'])
  88 + app.logger.info("Starting PDC-WEB")
  89 +
59 db.init_app(app) 90 db.init_app(app)
60 login_manager.init_app(app) 91 login_manager.init_app(app)
61 92
@@ -63,7 +94,11 @@ def create_app(config_class=None): @@ -63,7 +94,11 @@ def create_app(config_class=None):
63 # 94 #
64 from .main import bp as main_bp 95 from .main import bp as main_bp
65 app.register_blueprint(main_bp) 96 app.register_blueprint(main_bp)
  97 +
66 from .auth import bp as auth_bp 98 from .auth import bp as auth_bp
67 app.register_blueprint(auth_bp) 99 app.register_blueprint(auth_bp)
68 100
  101 + from .errors import bp as errors_bp
  102 + app.register_blueprint(errors_bp)
  103 +
69 return app 104 return app
app/auth/models.py 0 → 100644
@@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
  1 +from pprint import pprint
  2 +
  3 +from flask_login import UserMixin, current_user
  4 +from app.models import db
  5 +
  6 +#
  7 +# Roles
  8 +#
  9 +
  10 +ADMIN = 50
  11 +PROJECT = 40
  12 +SERVICE = 30
  13 +AGENT = 10
  14 +PUBLIC = 0
  15 +
  16 +_roleToName = {
  17 + ADMIN: 'ADMIN',
  18 + PROJECT: 'PROJECT',
  19 + SERVICE: 'SERVICE',
  20 + AGENT: 'AGENT',
  21 + PUBLIC: 'PUBLIC',
  22 +}
  23 +_nameToRole = {
  24 + 'ADMIN': ADMIN,
  25 + 'PROJECT': PROJECT,
  26 + 'SERVICE': SERVICE,
  27 + 'AGENT': AGENT,
  28 + 'PUBLIC': PUBLIC,
  29 +}
  30 +
  31 +
  32 +def _checkRole(role):
  33 + if isinstance(role, int):
  34 + rv = role
  35 + elif str(role) == role:
  36 + role = role.upper()
  37 + if role not in _nameToRole:
  38 + raise ValueError("Unknown role: %r" % role)
  39 + rv = _nameToRole[role]
  40 + else:
  41 + raise TypeError("Role not an integer or a valid string: %r" % role)
  42 + return rv
  43 +
  44 +
  45 +class User(UserMixin, db.Model):
  46 + id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy
  47 + email = db.Column(db.String(100), unique=True)
  48 + name = db.Column(db.String(100))
  49 + login = db.Column(db.String(100), unique=True)
  50 + password = db.Column(db.String(100))
  51 + role = db.Column(db.Integer, default=0)
  52 +
  53 + def __repr__(self):
  54 + return "i: {}, n: {}, e: {}, l: {}".format(self.id, self.name, self.email, self.login)
  55 +
  56 + # Set role at construction time
  57 + def __init__(self, **kwargs):
  58 + super(User, self).__init__(**kwargs)
  59 + self.set_role(kwargs['role'])
  60 +
  61 + def set_role(self, role):
  62 + self.role = _checkRole(role)
  63 +
  64 + def has_role(self, role):
  65 + role = _checkRole(role)
  66 + return self.role == role
  67 +
  68 + def has_role_or_higher(self, role):
  69 + role = _checkRole(role)
  70 + return self.role and (self.role >= role)
app/auth/routes.py
1 -from flask import render_template, request, redirect, url_for, flash 1 +from functools import wraps
  2 +
  3 +from flask_login import current_user
  4 +from flask import render_template, request, redirect, url_for, flash, current_app
2 from flask_login import login_user, logout_user 5 from flask_login import login_user, logout_user
3 -from app.models import User  
4 6
5 -from . import bp 7 +from app.auth.models import User
  8 +from app.auth import bp
  9 +
  10 +
  11 +#
  12 +# Decorator used to protect routes by role
  13 +# inspired from https://flask.palletsprojects.com/en/master/patterns/viewdecorators/
  14 +#
  15 +def role_required(role):
  16 + def decorator(f):
  17 + @wraps(f)
  18 + def decorated_function(*args, **kwargs):
  19 + try:
  20 + if current_app.config['ROLE_DISABLED']:
  21 + return f(*args, **kwargs)
  22 + except KeyError:
  23 + # no such config, juste ignore
  24 + pass
  25 + # first check use is logged in
  26 + if not current_user or not current_user.is_authenticated:
  27 + flash(f"Vous devez vous authentifier avec la fonction '{role}'", 'warning')
  28 + return redirect(url_for('auth.login'))
  29 + # then check role status
  30 + try:
  31 + is_authorised = current_user.has_role_or_higher(role)
  32 + except ValueError:
  33 + raise Exception("Unknowk role provided %s" % role)
  34 + if not is_authorised:
  35 + flash("Vous n'avez pas les autorisations pour accéder à cette page", 'dark')
  36 + return redirect(url_for('main.index'))
  37 + return f(*args, **kwargs)
  38 +
  39 + return decorated_function
  40 +
  41 + return decorator
6 42
7 43
8 @bp.route('/login') 44 @bp.route('/login')
app/auth/templates/login.html
@@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
2 <html lang="fr"> 2 <html lang="fr">
3 <head> 3 <head>
4 {% include 'heads.html' %} 4 {% include 'heads.html' %}
  5 + <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" type="text/css"/>
5 <link href="{{ url_for('auth.static', filename='signin.css') }}" rel="stylesheet" type="text/css"/> 6 <link href="{{ url_for('auth.static', filename='signin.css') }}" rel="stylesheet" type="text/css"/>
6 </head> 7 </head>
7 <body class="text-center"> 8 <body class="text-center">
@@ -9,7 +10,7 @@ @@ -9,7 +10,7 @@
9 {% include 'flash-messages.html' %} 10 {% include 'flash-messages.html' %}
10 11
11 <a href="{{url_for('main.index')}}"> 12 <a href="{{url_for('main.index')}}">
12 - <img class="mb-4" height="72" src="{{ url_for('static', filename='img/pdc.png') }}" width="72"> 13 + <img class="mb-4 {{config.PDC_SITE_CLASS}}" height="72" src="{{ url_for('static', filename='img/pdc.png') }}" width="72">
13 </a> 14 </a>
14 <h1 class="h3 font-weight-normal mb-3">Connectez vous</h1> 15 <h1 class="h3 font-weight-normal mb-3">Connectez vous</h1>
15 <label class="sr-only" for="login">Email Adress</label> 16 <label class="sr-only" for="login">Email Adress</label>
app/commands/commands.py
@@ -2,15 +2,15 @@ import sys @@ -2,15 +2,15 @@ import sys
2 import click 2 import click
3 import random 3 import random
4 4
  5 +from flask import current_app
5 from sqlalchemy.exc import OperationalError 6 from sqlalchemy.exc import OperationalError
6 from sqlalchemy.sql import func 7 from sqlalchemy.sql import func
7 from sqlalchemy.ext.automap import automap_base 8 from sqlalchemy.ext.automap import automap_base
8 from sqlalchemy.orm import Session 9 from sqlalchemy.orm import Session
9 from sqlalchemy import create_engine 10 from sqlalchemy import create_engine
10 11
11 -from app.models import db, User, Agent, Service, Project, Capacity, Period, Charge  
12 -  
13 -from db_config import mysql_lesia_uri 12 +from app.models import db, Agent, Service, Project, Capacity, Period, Charge
  13 +from app.auth.models import User
14 14
15 from . import bp 15 from . import bp
16 16
@@ -23,7 +23,7 @@ def feed_from_lesia(): @@ -23,7 +23,7 @@ def feed_from_lesia():
23 """ 23 """
24 Base = automap_base() 24 Base = automap_base()
25 25
26 - engine = create_engine(mysql_lesia_uri) 26 + engine = create_engine(current_app.config['LESIA_AGENTS_DB_URI'])
27 27
28 # reflect the tables 28 # reflect the tables
29 try: 29 try:
@@ -33,7 +33,6 @@ def feed_from_lesia(): @@ -33,7 +33,6 @@ def feed_from_lesia():
33 print("Please, configure the mysql database (see db_config.py)") 33 print("Please, configure the mysql database (see db_config.py)")
34 sys.exit(-1) 34 sys.exit(-1)
35 35
36 -  
37 # mapped classes are now created with names by default 36 # mapped classes are now created with names by default
38 # matching that of the table name. 37 # matching that of the table name.
39 LesiaAgent = Base.classes.agent 38 LesiaAgent = Base.classes.agent
@@ -97,7 +96,7 @@ def feed_random_charges(agent): @@ -97,7 +96,7 @@ def feed_random_charges(agent):
97 .filter(Charge.agent_id == agent_id, 96 .filter(Charge.agent_id == agent_id,
98 Charge.period_id == period_id 97 Charge.period_id == period_id
99 ).scalar() 98 ).scalar()
100 - if total_charge is not None and (total_charge + percent)>= 100: 99 + if total_charge is not None and (total_charge + percent) >= 100:
101 print("Skipping agent {} for period {}".format(agent_id, period_id)) 100 print("Skipping agent {} for period {}".format(agent_id, period_id))
102 continue 101 continue
103 charge = Charge(agent_id=agent_id, 102 charge = Charge(agent_id=agent_id,
@@ -124,7 +123,7 @@ def user_delete(user_id): @@ -124,7 +123,7 @@ def user_delete(user_id):
124 def create_db(): 123 def create_db():
125 """ Create the database structure.""" 124 """ Create the database structure."""
126 db.create_all() 125 db.create_all()
127 - admin = User(email='admin@nowhere.org', name='admin', login='admin', password='admin') 126 + admin = User(email='admin@nowhere.org', name='admin', login='admin', password='admin', role='admin')
128 db.session.add(admin) 127 db.session.add(admin)
129 db.session.commit() 128 db.session.commit()
130 129
app/errors/__init__.py 0 → 100644
@@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
  1 +from flask import Blueprint
  2 +
  3 +bp = Blueprint('errors', __name__, url_prefix='/auth', template_folder='templates')
  4 +
  5 +from . import handlers
app/errors/handlers.py 0 → 100644
@@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
  1 +from flask import render_template
  2 +from app import db
  3 +from . import bp
  4 +
  5 +
  6 +# Inspired by:
  7 +# https://flask.palletsprojects.com/en/master/patterns/errorpages/
  8 +
  9 +@bp.app_errorhandler(403)
  10 +def forbidden_error(error):
  11 + error_title = "Page Interdite"
  12 + return render_template('error.html', error_title=error_title, error_msg=error), 403
  13 +
  14 +
  15 +@bp.app_errorhandler(404)
  16 +def not_found_error(error):
  17 + error_title = "Page Introuvable."
  18 + return render_template('error.html', error_title=error_title, error_msg=error), 404
  19 +
  20 +
  21 +@bp.app_errorhandler(405)
  22 +def method_error(error):
  23 + error_title = "Erreur de Méthode."
  24 + return render_template('error.html', error_title=error_title, error_msg=error), 405
  25 +
  26 +
  27 +@bp.app_errorhandler(500)
  28 +def internal_error(error):
  29 + db.session.rollback()
  30 + error_title = "Erreur Interne. Administrateur Prévenu."
  31 + return render_template('error.html', error_title=error_title, error_msg=error), 500
app/errors/templates/error.html 0 → 100644
@@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
  1 +{% extends "base_page.html" %}
  2 +
  3 +{% block content %}
  4 +<h2 class="error_title">{{ error_title }}</h2>
  5 +<div class="error_msg">{{ error_msg }}</div>
  6 +<a href="{{url_for('main.index')}}">Retour à l'accueil</a>
  7 +{% endblock %}
app/main/routes.py
1 import json 1 import json
2 2
3 -from flask import render_template, make_response 3 +from flask import render_template, make_response, current_app, redirect, url_for, request
  4 +from flask_login import login_required, current_user
4 5
5 from . import bp 6 from . import bp
6 7
7 from app.models import Agent, Project, Service, Capacity, Period 8 from app.models import Agent, Project, Service, Capacity, Period
8 -from .. import db_mgr 9 +from app import db_mgr
  10 +from app.auth.routes import role_required
  11 +
  12 +
  13 +@bp.before_request
  14 +def site_login():
  15 + try:
  16 + if current_app.config['SITE_LOGIN'] \
  17 + and not current_user.is_authenticated:
  18 + return redirect(url_for('auth.login'))
  19 + except KeyError:
  20 + # no such config, juste ignore
  21 + pass
  22 +
  23 +@bp.before_request
  24 +def catch_all_route():
  25 + current_app.logger.info(f"{request.method} {request.path}")
9 26
10 27
11 @bp.route('/') 28 @bp.route('/')
@@ -14,6 +31,7 @@ def index(): @@ -14,6 +31,7 @@ def index():
14 31
15 32
16 @bp.route('/services') 33 @bp.route('/services')
  34 +@role_required('service')
17 def services(): 35 def services():
18 # get services list 36 # get services list
19 all_services = Service.query.order_by(Service.name).all() 37 all_services = Service.query.order_by(Service.name).all()
@@ -24,6 +42,7 @@ def services(): @@ -24,6 +42,7 @@ def services():
24 42
25 43
26 @bp.route('/projects') 44 @bp.route('/projects')
  45 +@role_required('project')
27 def projects(): 46 def projects():
28 # get projects list 47 # get projects list
29 all_projects = Project.query.order_by(Project.name).all() 48 all_projects = Project.query.order_by(Project.name).all()
@@ -34,6 +53,7 @@ def projects(): @@ -34,6 +53,7 @@ def projects():
34 53
35 54
36 @bp.route('/agents') 55 @bp.route('/agents')
  56 +@role_required('project')
37 def agents(): 57 def agents():
38 # get agents list 58 # get agents list
39 all_agents = Agent.query.order_by(Agent.firstname).all() 59 all_agents = Agent.query.order_by(Agent.firstname).all()
@@ -44,6 +64,7 @@ def agents(): @@ -44,6 +64,7 @@ def agents():
44 64
45 65
46 @bp.route('/capacities') 66 @bp.route('/capacities')
  67 +@login_required
47 def capacities(): 68 def capacities():
48 # get capacities list 69 # get capacities list
49 all_capacities = Capacity.query.order_by(Capacity.name).all() 70 all_capacities = Capacity.query.order_by(Capacity.name).all()
@@ -54,6 +75,7 @@ def capacities(): @@ -54,6 +75,7 @@ def capacities():
54 75
55 76
56 @bp.route('/periods') 77 @bp.route('/periods')
  78 +@login_required
57 def periods(): 79 def periods():
58 # get capacities list 80 # get capacities list
59 all_periods = Period.query.order_by(Period.name).all() 81 all_periods = Period.query.order_by(Period.name).all()
@@ -64,11 +86,13 @@ def periods(): @@ -64,11 +86,13 @@ def periods():
64 86
65 87
66 @bp.route('/charge/add') 88 @bp.route('/charge/add')
  89 +@role_required('service')
67 def charge_add(): 90 def charge_add():
68 return render_template('charge.html', subtitle="Affecter un agent") 91 return render_template('charge.html', subtitle="Affecter un agent")
69 92
70 93
71 @bp.route('/charge/agent/<agent_id>') 94 @bp.route('/charge/agent/<agent_id>')
  95 +@role_required('service')
72 def charge_agent(agent_id): 96 def charge_agent(agent_id):
73 agent_charges = [] 97 agent_charges = []
74 for [period, charge] in db_mgr.charges_by_agent(agent_id): 98 for [period, charge] in db_mgr.charges_by_agent(agent_id):
@@ -80,8 +104,10 @@ def charge_agent(agent_id): @@ -80,8 +104,10 @@ def charge_agent(agent_id):
80 104
81 105
82 @bp.route('/agent/<agent_id>') 106 @bp.route('/agent/<agent_id>')
  107 +@role_required('agent')
83 def agent(agent_id): 108 def agent(agent_id):
84 - agent = Agent.query.get(int(agent_id)) 109 + # TODO: am i the agent, the service or project manager , or the admin ?
  110 + this_agent = Agent.query.get(int(agent_id))
85 return render_template('agent.html', 111 return render_template('agent.html',
86 - agent=agent, 112 + agent=this_agent,
87 subtitle="{} {}".format(agent.firstname, agent.secondname)) 113 subtitle="{} {}".format(agent.firstname, agent.secondname))
1 -from flask_login import UserMixin  
2 from flask_sqlalchemy import SQLAlchemy 1 from flask_sqlalchemy import SQLAlchemy
3 2
4 db = SQLAlchemy() 3 db = SQLAlchemy()
5 4
6 5
7 -class User(UserMixin, db.Model):  
8 - id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy  
9 - email = db.Column(db.String(100), unique=True)  
10 - name = db.Column(db.String(100))  
11 - login = db.Column(db.String(100), unique=True)  
12 - password = db.Column(db.String(100))  
13 -  
14 - def __repr__(self):  
15 - return "i: {}, n: {}, e: {}, l: {}".format(self.id, self.name, self.email, self.login)  
16 -  
17 -  
18 class Agent(db.Model): 6 class Agent(db.Model):
19 id = db.Column(db.Integer, primary_key=True) 7 id = db.Column(db.Integer, primary_key=True)
20 firstname = db.Column(db.String(100)) 8 firstname = db.Column(db.String(100))
app/static/css/style.css
@@ -57,3 +57,22 @@ nav.sidebar a.disabled { @@ -57,3 +57,22 @@ nav.sidebar a.disabled {
57 padding-bottom: 15px; 57 padding-bottom: 15px;
58 border-bottom: 1px solid #ddd 58 border-bottom: 1px solid #ddd
59 } 59 }
  60 +
  61 +/* -- - - - - - - - - - per site icon settings - - - - - - - - - -- */
  62 +
  63 +.admin-icon {
  64 + filter: hue-rotate(300deg) saturate(1000%);
  65 +}
  66 +
  67 +.public-icon {
  68 + filter: saturate(200%);
  69 + /* hue-rotate(300deg) */
  70 +}
  71 +
  72 +.dev-icon {
  73 + filter: hue-rotate(200deg) saturate(1000%);
  74 +}
  75 +
  76 +.dev2-icon {
  77 + filter: hue-rotate(100deg) saturate(1000%);
  78 +}
app/templates/base_page.html
@@ -9,8 +9,8 @@ @@ -9,8 +9,8 @@
9 <body> 9 <body>
10 <nav class="navbar navbar-dark sticky-top bg-dark navbar-expand-lg p-1"> 10 <nav class="navbar navbar-dark sticky-top bg-dark navbar-expand-lg p-1">
11 <a class="navbar-brand col-sm-3 col-md-2 " href="{{url_for('main.index')}}"> 11 <a class="navbar-brand col-sm-3 col-md-2 " href="{{url_for('main.index')}}">
12 - <img class="m-0 mr-1" height="30" src="{{ url_for('static', filename='img/pdc-ico.png') }}" width="30"/>  
13 - Plan de Charges</a> 12 + <img class="m-0 mr-1 {{config.PDC_SITE_CLASS}}" src="{{ url_for('static', filename='img/pdc-ico.png') }}" height="30" width="30"/>
  13 + {{config.PDC_APP_NAME}} - {{config.PDC_SITE_NAME}}</a>
14 <ul class="navbar-nav ml-auto"> 14 <ul class="navbar-nav ml-auto">
15 {% if not current_user.is_anonymous %} 15 {% if not current_user.is_anonymous %}
16 <li class="nav-item"> 16 <li class="nav-item">
app/templates/copy.html
1 <p class="text-muted text-center"> 1 <p class="text-muted text-center">
2 - Plan de Charge - {{ config.VERSION }}<br/> 2 + {{ config.PDC_APP_NAME }} - {{ config.VERSION }}<br/>
3 © 2021 IRAP 3 © 2021 IRAP
4 </p> 4 </p>
app/templates/flash-messages.html
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 <div id="messages" class="mt-1"> 3 <div id="messages" class="mt-1">
4 {% for level, message in messages %} 4 {% for level, message in messages %}
5 {% if level == 'message' %} 5 {% if level == 'message' %}
6 - <div class="alert alert-primary}" role="alert">{{ message }} 6 + <div class="alert alert-primary" role="alert">{{ message }}
7 {% else %} 7 {% else %}
8 <div class="alert alert-{{level}}" role="alert">{{ message }} 8 <div class="alert alert-{{level}}" role="alert">{{ message }}
9 {% endif %} 9 {% endif %}
app/templates/heads.html
@@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
2 <meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport"> 2 <meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport">
3 <link href="{{ url_for('static', filename='img/pdc-ico.png') }}" rel="icon"/> 3 <link href="{{ url_for('static', filename='img/pdc-ico.png') }}" rel="icon"/>
4 <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/> 4 <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
5 - <title>{% if title %} {{title}} {% else %}Plan de Charge{%endif%}</title>  
6 \ No newline at end of file 5 \ No newline at end of file
  6 + <title>{% if title %} {{title}} {% else %}{{config.PDC_APP_NAME}} - {{config.PDC_SITE_NAME}}{%endif%}</title>
7 \ No newline at end of file 7 \ No newline at end of file
migrations/versions/4d5e5e207063_.py 0 → 100644
@@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
  1 +"""empty message
  2 +
  3 +Revision ID: 4d5e5e207063
  4 +Revises: 6f7e51f380eb
  5 +Create Date: 2021-03-26 18:34:03.768661
  6 +
  7 +"""
  8 +from alembic import op
  9 +import sqlalchemy as sa
  10 +
  11 +
  12 +# revision identifiers, used by Alembic.
  13 +revision = '4d5e5e207063'
  14 +down_revision = '6f7e51f380eb'
  15 +branch_labels = None
  16 +depends_on = None
  17 +
  18 +
  19 +def upgrade():
  20 + # ### commands auto generated by Alembic - please adjust! ###
  21 + op.add_column('user', sa.Column('role', sa.Integer(), nullable=True))
  22 + # ### end Alembic commands ###
  23 +
  24 +
  25 +def downgrade():
  26 + # ### commands auto generated by Alembic - please adjust! ###
  27 + op.drop_column('user', 'role')
  28 + # ### end Alembic commands ###
pdc_web.sh 0 → 100755
@@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
  1 +#/bin/env sh
  2 +
  3 +#
  4 +# Shell wrapper to locally run the flask app as any user:
  5 +#
  6 +# sudo -u www-data ./pdc_web.sh
  7 +#
  8 +# Of course, any prerequisites, installation procedure and configuration files may have been fullfilled.
  9 +# See README.md and INSTALL.md for more details
  10 +#
  11 +
  12 +SCRIPT_PATH=$(readlink -f $0)
  13 +SCRIPT_DIR=$(dirname $SCRIPT_PATH)
  14 +
  15 +cd $SCRIPT_DIR
  16 +
  17 +[ ! -f ./venv/bin/activate ] && echo "Create python virtual env ./venv/" && exit
  18 +[ ! -f .flaskenv ] && echo "Create .flaskenv file" && exit
  19 +
  20 +. ./venv/bin/activate
  21 +flask run
resources/db_config.py
@@ -39,6 +39,7 @@ POSTGRESTEST = { @@ -39,6 +39,7 @@ POSTGRESTEST = {
39 } 39 }
40 postgres_test_uri = 'postgresql://%(user)s:%(pw)s@%(host)s:%(port)s/%(db)s' % POSTGRESTEST 40 postgres_test_uri = 'postgresql://%(user)s:%(pw)s@%(host)s:%(port)s/%(db)s' % POSTGRESTEST
41 41
  42 +# The lesia mysql agents database
42 MYSQL_LESIA = { 43 MYSQL_LESIA = {
43 'user': 'mysql', 44 'user': 'mysql',
44 'pw': 'mysql', 45 'pw': 'mysql',
resources/farm-index.html
@@ -75,10 +75,10 @@ @@ -75,10 +75,10 @@
75 <h1 class="h3 font-weight-normal my-5">Plan de Charge</h1> 75 <h1 class="h3 font-weight-normal my-5">Plan de Charge</h1>
76 76
77 <p class="lead"> 77 <p class="lead">
78 - Place centrale de redirection vers les différents sites de la ferme d'applications sur le site de l'Irap. 78 + Les différentes instances de l'application hébergées à l'Irap:
79 </p> 79 </p>
80 <p class="lead"> 80 <p class="lead">
81 - <a class="btn btn-lg btn-secondary" href="https://plan-de-charges.irap.omp.eu/public/">Pour tous</a> 81 + <a class="btn btn-lg btn-secondary" href="https://plan-de-charges.irap.omp.eu/public/">Public</a>
82 <a class="btn btn-lg btn-secondary" href="https://plan-de-charges.irap.omp.eu/irap/">Irap</a> 82 <a class="btn btn-lg btn-secondary" href="https://plan-de-charges.irap.omp.eu/irap/">Irap</a>
83 </p> 83 </p>
84 </main> 84 </main>
pdc_config.py renamed to resources/pdc_config.py
@@ -10,6 +10,38 @@ root_dir = os.path.abspath(os.path.dirname(__file__)) @@ -10,6 +10,38 @@ root_dir = os.path.abspath(os.path.dirname(__file__))
10 10
11 class Config(object): 11 class Config(object):
12 SECRET_KEY = 'dev' 12 SECRET_KEY = 'dev'
  13 +
  14 + # Please change the following to fit you own site parameters
  15 + #
  16 + PDC_APP_NAME = 'Plan de Charge'
  17 + PDC_SITE_NAME = 'NO_SITE' # choose among IRAP, PUBLIC, ...
  18 + PDC_SITE_CLASS = 'public-icon' # choose among admin-icon, public-icon
  19 + PDC_LOGS_LEVEL = 'DEBUG' # choose within DEBUG, INFO, WARN, ERROR ( more levels in logging module )
  20 + PDC_LOGS_DIR = os.path.join(root_dir, 'logs')
  21 + PDC_LOGS_FILE = os.path.join(PDC_LOGS_DIR, 'pdc.log')
  22 +
  23 + # Uncomment for role access control
  24 + # if True, will disable any role control on routes
  25 + # note that this doesnt disable the @login_required
  26 + #
  27 + # ROLE_DISABLED = False
  28 +
  29 + # Uncomment for site access control
  30 + # if True, will force login access on any site page
  31 + # note that this doesnt disable the @login_required
  32 + #
  33 + # SITE_LOGIN = False
  34 +
  35 + #
  36 + # No need to Edit below
  37 + #
  38 +
  39 + # You can force logging to stdout in production environment
  40 + # (make sure your httpd/wsgi server can redirect to log files)
  41 + LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT')
  42 + if LOG_TO_STDOUT and LOG_TO_STDOUT.upper() == "true".upper():
  43 + LOG_TO_STDOUT = True
  44 +
13 SQLALCHEMY_TRACK_MODIFICATIONS = False 45 SQLALCHEMY_TRACK_MODIFICATIONS = False
14 46
15 # Trying to set specific db uri from ./db_config.py 47 # Trying to set specific db uri from ./db_config.py
@@ -19,6 +51,12 @@ class Config(object): @@ -19,6 +51,12 @@ class Config(object):
19 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 51 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
20 'sqlite:///' + os.path.join(root_dir, 'pdc_app.db') 52 'sqlite:///' + os.path.join(root_dir, 'pdc_app.db')
21 53
  54 + try:
  55 + LESIA_AGENTS_DB_URI = mysql_lesia_uri
  56 + except NameError:
  57 + LESIA_AGENTS_DB_URI = os.environ.get('LESIA_AGENTS_DB_URI') or \
  58 + 'sqlite:///' + os.path.join(root_dir, 'lesia.db')
  59 +
22 with open(os.path.join(root_dir, 'VERSION.txt')) as version_file: 60 with open(os.path.join(root_dir, 'VERSION.txt')) as version_file:
23 VERSION = version_file.read().strip() 61 VERSION = version_file.read().strip()
24 62
@@ -36,11 +74,16 @@ class DevConfig(Config): @@ -36,11 +74,16 @@ class DevConfig(Config):
36 except NameError: 74 except NameError:
37 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 75 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
38 'sqlite:///' + os.path.join(root_dir, 'pdc_app_dev.db') 76 'sqlite:///' + os.path.join(root_dir, 'pdc_app_dev.db')
  77 + # ignores @login_required decorator
  78 + LOGIN_DISABLED = True
  79 + # ignores @role_required decorator
  80 + ROLE_DISABLED = True
39 81
40 82
41 class TestConfig(Config): 83 class TestConfig(Config):
42 TESTING = True 84 TESTING = True
43 DEBUG = True 85 DEBUG = True
  86 + PDC_LOGS_LEVEL = 'ERROR' # choose within DEBUG, INFO, WARN, ERROR ( more levels in logging module )
44 # Trying to set specific db uri from ./db_config.py 87 # Trying to set specific db uri from ./db_config.py
45 try: 88 try:
46 SQLALCHEMY_DATABASE_URI = sqlalchemy_testdb_uri 89 SQLALCHEMY_DATABASE_URI = sqlalchemy_testdb_uri
@@ -48,6 +91,8 @@ class TestConfig(Config): @@ -48,6 +91,8 @@ class TestConfig(Config):
48 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 91 SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
49 'sqlite:///' + os.path.join(root_dir, 'pdc_app_test.db') 92 'sqlite:///' + os.path.join(root_dir, 'pdc_app_test.db')
50 # ignores @login_required decorator 93 # ignores @login_required decorator
51 - # LOGIN_DISABLED = True 94 + LOGIN_DISABLED = True
  95 + # ignores @role_required decorator
  96 + ROLE_DISABLED = True
52 97
53 # vim: tw=0 98 # vim: tw=0
tests/backend_tests.py
1 import unittest 1 import unittest
2 from pdc_config import TestConfig 2 from pdc_config import TestConfig
3 -from app import create_app, db_mgr 3 +from app import create_app, db_mgr, db
  4 +from app.auth.models import User
4 5
5 6
6 class BaseTestCase(unittest.TestCase): 7 class BaseTestCase(unittest.TestCase):
@@ -9,6 +10,10 @@ class BaseTestCase(unittest.TestCase): @@ -9,6 +10,10 @@ class BaseTestCase(unittest.TestCase):
9 self.app = create_app(TestConfig) 10 self.app = create_app(TestConfig)
10 self.app_context = self.app.app_context() 11 self.app_context = self.app.app_context()
11 self.app_context.push() 12 self.app_context.push()
  13 + db.create_all()
  14 + admin = User(email='admin@nowhere.org', name='admin', login='admin', password='admin', role='admin')
  15 + db.session.add(admin)
  16 + db.session.commit()
12 17
13 def tearDown(self): 18 def tearDown(self):
14 self.app_context.pop() 19 self.app_context.pop()
@@ -27,3 +32,20 @@ class DbMgrTestCase(BaseTestCase): @@ -27,3 +32,20 @@ class DbMgrTestCase(BaseTestCase):
27 def test_charges_by_agent(self): 32 def test_charges_by_agent(self):
28 all_charges = db_mgr.charges_by_agent(355) 33 all_charges = db_mgr.charges_by_agent(355)
29 self.assertEqual(6, len(all_charges)) 34 self.assertEqual(6, len(all_charges))
  35 +
  36 +
  37 +class AuthModelTestCase(BaseTestCase):
  38 +
  39 + def test_setrole(self):
  40 + admin = User.query.filter(User.name == 'admin').one_or_none()
  41 + admin.set_role("ADMIN")
  42 + db.session.commit()
  43 + admin = User.query.filter(User.name == 'admin').one_or_none()
  44 + self.assertTrue(admin is not None)
  45 + self.assertTrue(admin.has_role("ADMIN"))
  46 + self.assertFalse(admin.has_role("SERVICE"))
  47 +
  48 + def test_setrole_valueerror(self):
  49 + admin = User(email='me@nowhere.org', name='me', login='me', password='me', role='admin')
  50 + with self.assertRaises(ValueError) as ve:
  51 + admin.set_role("NOSUCHROLE")