Commit 9332a554747a59bbb348f0f82bad0a75ca89bd7f
Exists in
master
and in
4 other branches
File logging and route access control
Showing
25 changed files
with
385 additions
and
42 deletions
Show diff stats
.gitignore
CHANGELOG.md
@@ -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 |
INSTALL.md
@@ -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 |
VERSION.txt
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 |
@@ -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 |
@@ -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/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)) |
app/models.py
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
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 |
@@ -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 ### |
@@ -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") |