commit ac3e5414fe93f4d538179ba40d56e7a6ed3e5a1f Author: Arthur Grisel-Davy Date: Sun Aug 5 15:08:58 2018 +0200 Commit initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..375b681 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +## Re2o - DHCP + +This service uses Re2o API to generate DHCP leases files + + +## Requirements + +* python3 +* requirements in https://gitlab.federez.net/re2o/re2oapi diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..9c28e1d --- /dev/null +++ b/config.ini @@ -0,0 +1,5 @@ +[Re2o] +hostname = grizzly2o.crans.org +username = passoir +password = plopplop + diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..60a6b33 --- /dev/null +++ b/config.ini.example @@ -0,0 +1,4 @@ +[Re2o] +hostname = re2o.example.net +username = my_api_username +password = my_api_password diff --git a/main.py b/main.py new file mode 100644 index 0000000..3f04c5f --- /dev/null +++ b/main.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +from configparser import ConfigParser +import socket + +from re2oapi import Re2oAPIClient +from django.core.mail import send_mail +from django.template import loader, Context + +from pprint import pprint + +config = ConfigParser() +config.read('config.ini') + +api_hostname = config.get('Re2o', 'hostname') +api_password = config.get('Re2o', 'password') +api_username = config.get('Re2o', 'username') + +api_client = Re2oAPIClient(api_hostname,api_username,api_password) + +client_hostname = socket.gethostname().split('.',1)[0] + +def notif_end_adhesion(api_client): + asso_options = api_client.list("preferences/assooption") + from_mail = api_client.list("preferences/generaloption")["email_from"] + template = loader.get_template('email_fin_adhesion') + + for result in api_client.list("reminder/get-users"): + for user in result["users_to_remind"]: + context = Context({ + 'nom': user["get_full_name"], + 'temps': result["days"], + 'asso_name': asso_options["name"], + 'link': asso_options["site_url"] + }) + print('mail envoyé à {}, reminder {} days'.format(user["get_full_name"],result["days"])) + send_mail("Avis de fin d'adhésion / End of subscription notice", + '', + from_mail, + user["email"], + html_message = template.render(context) + ) + diff --git a/re2oapi/.gitignore b/re2oapi/.gitignore new file mode 100644 index 0000000..61f2dc9 --- /dev/null +++ b/re2oapi/.gitignore @@ -0,0 +1 @@ +**/__pycache__/ diff --git a/re2oapi/README.md b/re2oapi/README.md new file mode 100644 index 0000000..f4196a1 --- /dev/null +++ b/re2oapi/README.md @@ -0,0 +1,8 @@ +## Re2o - API client + +This project is providing an abstraction layer to easily use the API of Re2o. + +## Requirements + +* python3 +* python3-iso8601 diff --git a/re2oapi/__init__.py b/re2oapi/__init__.py new file mode 100644 index 0000000..92f9065 --- /dev/null +++ b/re2oapi/__init__.py @@ -0,0 +1 @@ +from .re2oapi import * diff --git a/re2oapi/re2oapi/__init__.py b/re2oapi/re2oapi/__init__.py new file mode 100644 index 0000000..5939662 --- /dev/null +++ b/re2oapi/re2oapi/__init__.py @@ -0,0 +1,4 @@ +from .client import Re2oAPIClient +from . import exceptions + +__all__ = ['Re2oAPIClient', 'exceptions'] diff --git a/re2oapi/re2oapi/client.py b/re2oapi/re2oapi/client.py new file mode 100644 index 0000000..6348c8e --- /dev/null +++ b/re2oapi/re2oapi/client.py @@ -0,0 +1,557 @@ +import logging +import datetime +import iso8601 +from pathlib import Path +import stat +import json +import requests +from requests.exceptions import HTTPError + +from . import exceptions + +# Number of seconds before expiration where renewing the token is done +TIME_FOR_RENEW = 60 +# Default name of the file to store tokens +DEFAULT_TOKEN_FILENAME = '.re2o.token' + + +class Re2oAPIClient: + """Wrapper to handle the requests to Re2o API in a seemless way. + + You must first initialize the client and then you can access + the API with dedicated functions that handle the authentication + process for you. + """ + + def __init__(self, hostname, username, password, token_file=None, + use_tls=True, log_level=logging.CRITICAL+10): + """Creates an API client. + + Args: + hostname: The hostname of the Re2o server to use. + username: The username to use. + password: The password to use. + token_file: An optional path to the file where re2o tokens are + stored. Used both for retrieving the token and saving it, so + the file must be accessible for reading and writing. The + default value is `None`, which indicated to use + `$HOME/{DEFAULT_TOKEN_FILENAME}`. + use_tls: A boolean to indicate whether the client should us TLS + (recommended for production). The default is `True`. + log_level: Control the logging level to use. The default is + `logging.CRITICAL+10`. So nothing is logged. + + Raises: + requests.exceptions.ConnectionError: Unable to resolve the + provided hostname. + requests.exceptions.HTTPError: The server used does not have a + valid Re2o API. + re2oapi.exceptions.InvalidCredentials: The credentials provided + are not valid according to the Re2o server. + """.format(DEFAULT_TOKEN_FILENAME=DEFAULT_TOKEN_FILENAME) + + # Enable logging + self.log = logging.getLogger(__name__) + if not self.log.hasHandlers(): + # Avoid multiplicating handlers + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s %(message)s" + ) + handler.setFormatter(formatter) + self.log.addHandler(handler) + self.log.setLevel(log_level) + + self.log.info("Starting new Re2o API client.") + self.log.debug("hostname = " + str(hostname)) + self.log.debug("username = " + str(username)) + self.log.debug("token_file = " + str(token_file)) + self.log.debug("use_tls = " + str(use_tls)) + + self.use_tls = use_tls + self.token_file = token_file or Path.home() / DEFAULT_TOKEN_FILENAME + self.hostname = hostname + self._username = username + self._password = password + # Try to fetch token from token file else get a new one from the + # server + try: + self.token = self._get_token_from_file() + except exceptions.APIClientGenericError: + self._force_renew_token() + + @property + def need_renew_token(self): + """The token needs to be renewed. + + Returns: + True is the token expiration time is within less than + {TIME_FOR_RENEW} seconds. + """.format(TIME_FOR_RENEW=TIME_FOR_RENEW) + + return self.token['expiration'] < \ + datetime.datetime.now(datetime.timezone.utc) + \ + datetime.timedelta(seconds=TIME_FOR_RENEW) + + def _get_token_from_file(self): + self.log.debug("Retrieving token from token file '{}'." + .format(self.token_file)) + + # Check the token file exists + if not self.token_file.is_file(): + e = exceptions.TokenFileNotFound(self.token_file) + self.log.error(e) + raise e + + # Read the data in the file + try: + with self.token_file.open() as f: + data = json.load(f) + except Exception: + e = exceptions.TokenFileNotReadable(self.token_file) + self.log.error(e) + raise e + + try: + # Retrieve data for this hostname and this username in the file + token_data = data[self.hostname][self._username] + ret = { + 'token': token_data['token'], + 'expiration': iso8601.parse_date(token_data['expiration']) + } + except KeyError: + e = exceptions.TokenNotInTokenFile( + self._username, + self.hostname, + self.token_file + ) + self.log.error(e) + raise e + else: + self.log.debug("Token successfully retrieved from token " + "file '{}'.".format(self.token_file)) + return ret + + def _save_token_to_file(self): + self.log.debug("Saving token to token file '{}'." + .format(self.token_file)) + + try: + # Read previous data to not erase other tokens + with self.token_file.open() as f: + data = json.load(f) + except Exception: + # Default value if file not readbale or data not valid JSON + self.log.warning("Token file '{}' is not a valid JSON readable " + "file. Considered empty.".format(self.token_file)) + data = {} + + # Insert the new token in the data (replace old token if it exists) + if self.hostname not in data.keys(): + data[self.hostname] = {} + data[self.hostname][self._username] = { + 'token': self.token['token'], + 'expiration': self.token['expiration'].isoformat() + } + + # Rewrite the token file and ensure only the user can read it + # Fails silently if the file cannot be written. + try: + with self.token_file.open('w') as f: + json.dump(data, f) + self.token_file.chmod(stat.S_IWRITE | stat.S_IREAD) + except Exception: + self.log.error("Token file '{}' could not be written. Passing." + .format(self.token_file)) + else: + self.log.debug("Token sucessfully writen in token file '{}'." + .format(self.token_file)) + + def _get_token_from_server(self): + self.log.debug("Requesting a new token form server '{}' for user " + "'{}'.".format(self.hostname, self._username)) + + # Perform the authentication request + response = requests.post( + self.get_url_for('token-auth'), + data={'username': self._username, 'password': self._password} + ) + self.log.debug("Response code: "+str(response.status_code)) + + if response.status_code == requests.codes.bad_request: + e = exceptions.InvalidCredentials(self._username, self.hostname) + self.log.error(e) + raise e + response.raise_for_status() + + # Return the token and expiration time + response = response.json() + ret = { + 'token': response['token'], + 'expiration': iso8601.parse_date(response['expiration']) + } + self.log.debug("Token successfully retrieved from server '{}'." + .format(self.hostname)) + return ret + + def _force_renew_token(self): + self.token = self._get_token_from_server() + self._save_token_to_file() + + def get_token(self): + """Retrieves the token to use for the current connection. + + Returns: + The token to use in the request as an authentication. It is + automatically renewed if needed. + + Raises: + re2oapi.exceptions.InvalidCredentials: The token needs to be + renewed but the given credentials are not valid. + """ + if self.need_renew_token: + # Renew the token only if needed + self._force_renew_token() + return self.token['token'] + + def _request(self, method, url, headers={}, params={}, *args, **kwargs): + self.log.info("Building the request {} {}.".format(method.upper(), url)) + + # Update headers to force the 'Authorization' field with the right token + self.log.debug("Forcing authentication token.") + headers.update({ + 'Authorization': 'Token {}'.format(self.get_token()) + }) + + # Use a json format unless the user already specified something + if not 'format' in params.keys(): + self.log.debug("Forcing JSON format response.") + params.update({'format': 'json'}) + + # Perform the request + self.log.info("Performing request {} {}".format(method.upper(), url)) + response = getattr(requests, method)( + url, headers=headers, params=params, *args, **kwargs + ) + self.log.debug("Response code: "+str(response.status_code)) + + if response.status_code == requests.codes.unauthorized: + # Force re-login to the server (case of a wrong token but valid + # credentials) and then retry the request without catching errors. + self.log.warning("Token refused. Trying to refresh the token.") + self._force_renew_token() + + headers.update({ + 'Authorization': 'Token {}'.format(self.get_token()) + }) + self.log.info("Re-performing the request {} {}" + .format(method.upper(), url)) + response = getattr(requests, method)( + url, headers=headers, params=params, *args, **kwargs + ) + self.log.debug("Response code: "+str(response.status_code)) + + if response.status_code == requests.codes.forbidden: + e = exceptions.PermissionDenied(method, url, self._username) + self.log.debug(e) + raise e + response.raise_for_status() + + ret = response.json() + self.log.debug("Request {} {} successful.".format(method, url)) + return ret + + def delete(self, *args, **kwargs): + """Performs a DELETE request. + + DELETE request on a given URL that acts like `requests.delete` except + that authentication to the API is automatically done and JSON response + is decoded. + + Args: + url: The URL of the requests. + *args: See `requests.delete` parameters. + **kwargs: See `requests.delete` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('delete', *args, **kwargs) + + def get(self, *args, **kwargs): + """Performs a GET request. + + GET request on a given URL that acts like `requests.get` except that + authentication to the API is automatically done and JSON response is + decoded. + + Args: + url: The URL of the requests. + *args: See `requests.get` parameters. + **kwargs: See `requests.get` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('get', *args, **kwargs) + + def head(self, *args, **kwargs): + """Performs a HEAD request. + + HEAD request on a given URL that acts like `requests.head` except that + authentication to the API is automatically done and JSON response is + decoded. + + Args: + url: The URL of the requests. + *args: See `requests.head` parameters. + **kwargs: See `requests.head` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('get', *args, **kwargs) + + def option(self, *args, **kwargs): + """Performs a OPTION request. + + OPTION request on a given URL that acts like `requests.option` except + that authentication to the API is automatically done and JSON response + is decoded. + + Args: + url: The URL of the requests. + *args: See `requests.option` parameters. + **kwargs: See `requests.option` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('get', *args, **kwargs) + + def patch(self, *args, **kwargs): + """Performs a PATCH request. + + PATCH request on a given URL that acts like `requests.patch` except + that authentication to the API is automatically done and JSON response + is decoded. + + Args: + url: The URL of the requests. + *args: See `requests.patch` parameters. + **kwargs: See `requests.patch` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('patch', *args, **kwargs) + + def post(self, *args, **kwargs): + """Performs a POST request. + + POST request on a given URL that acts like `requests.post` except that + authentication to the API is automatically done and JSON response is + decoded. + + Args: + url: The URL of the requests. + *args: See `requests.post` parameters. + **kwargs: See `requests.post` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('post', *args, **kwargs) + + def put(self, *args, **kwargs): + """Performs a PUT request. + + PUT request on a given URL that acts like `requests.put` except that + authentication to the API is automatically done and JSON response is + decoded. + + Args: + url: The URL of the requests. + *args: See `requests.put` parameters. + **kwargs: See `requests.put` parameters. + + Returns: + The JSON-decoded result of the request. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + return self._request('put', *args, **kwargs) + + def get_url_for(self, endpoint): + """Retrieve the complete URL to use for a given endpoint's name. + + Args: + endpoint: The path of the endpoint. + **kwargs: A dictionnary with the parameter to use to build the + URL (using .format() syntax) + + Returns: + The full URL to use. + + Raises: + re2oapi.exception.NameNotExists: The provided name does not + correspond to any endpoint. + """ + return '{proto}://{host}/{namespace}/{endpoint}'.format( + proto=('https' if self.use_tls else 'http'), + host=self.hostname, + namespace='api', + endpoint=endpoint + ) + + def list(self, endpoint, max_results=None, params={}): + """List all objects on the server that corresponds to the given + endpoint. The endpoint must be valid for listing objects. + + Args: + endpoint: The path of the endpoint. + max_results: A limit on the number of result to return + params: See `requests.get` params. + + Returns: + The list of all the objects serialized as returned by the API. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + self.log.info("Starting listing objects under '{}'" + .format(endpoint)) + self.log.debug("max_results = "+str(max_results)) + + # For optimization, list all results in one page unless the user + # is forcing the use of a different `page_size`. + if not 'page_size' in params.keys(): + self.log.debug("Forcing 'page_size' parameter to 'all'.") + params['page_size'] = max_results or 'all' + + # Performs the request for the first page + response = self.get( + self.get_url_for(endpoint), + params=params + ) + results = response['results'] + + # Get all next pages and append the results + while response['next'] is not None and \ + (max_results is None or len(results) < max_results): + response = self.get(response['next']) + results += response['results'] + + # Returns the exact number of results if applicable + ret = results[:max_results] if max_results else results + self.log.debug("Listing objects under '{}' successful" + .format(endpoint)) + return ret + + def count(self, endpoint, params={}): + """Count all objects on the server that corresponds to the given + endpoint. The endpoint must be valid for listing objects. + + Args: + endpoint: The path of the endpoint. + params: See `requests.get` params. + + Returns: + The number of objects on the server as returned by the API. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + self.log.info("Starting counting objects under '{}'" + .format(endpoint)) + + # For optimization, ask for only 1 result (so the server will take + # less time to process the request) unless the user is forcing the + # use of a different `page_size`. + if not 'page_size' in params.keys(): + self.log.debug("Forcing 'page_size' parameter to '1'.") + params['page_size'] = 1 + + # Performs the request and return the `count` value in the response. + ret = self.get( + self.get_url_for(endpoint), + params=params + )['count'] + + self.log.debug("Counting objects under '{}' successful" + .format(endpoint)) + return ret + + def view(self, endpoint, params={}): + """Retrieved the details of an object from the server that corresponds + to the given endpoint. + + Args: + endpoint: The path of the endpoint. + params: See `requests.get` params. + + Returns: + The serialized data of the queried object as returned by the API. + + Raises: + requests.exceptions.RequestException: An error occured while + performing the request. + exceptions.PermissionDenied: The user does not have the right + to perform this request. + """ + self.log.info("Starting viewing an object under '{}'" + .format(endpoint)) + ret = self.get( + self.get_url_for(endpoint), + params=params + ) + + self.log.debug("Viewing object under '{}' successful" + .format(endpoint)) + return ret diff --git a/re2oapi/re2oapi/exceptions.py b/re2oapi/re2oapi/exceptions.py new file mode 100644 index 0000000..7522485 --- /dev/null +++ b/re2oapi/re2oapi/exceptions.py @@ -0,0 +1,27 @@ +class APIClientGenericError(ValueError): + template = "{}" + + def __init__(self, *data): + self.data = data + self.message = self.template.format(*data) + super(APIClientGenericError, self).__init__(self.message) + + +class InvalidCredentials(APIClientGenericError): + template = "The credentials for {}@{} are not valid." + + +class PermissionDenied(APIClientGenericError): + template = "The {} request to '{}' was denied for {}." + + +class TokenFileNotFound(APIClientGenericError): + template = "Token file at {} not found." + + +class TokenFileNotReadable(APIClientGenericError): + template = "Token file at {} is not a JSON readable file." + + +class TokenNotInTokenFile(APIClientGenericError): + template = "Token for {}@{} not found in token file ({})." diff --git a/templates/email_fin_adhesion b/templates/email_fin_adhesion new file mode 100644 index 0000000..7f16f13 --- /dev/null +++ b/templates/email_fin_adhesion @@ -0,0 +1,27 @@ +

Bonjour {{nom}},

+ +

Ton adhésion arrive à son therme dans {{temps}} jours.

+ +{% if online %} +

Tu peux renouveller ton adhésion en ligne sur Mon Profil.

+

Tu peux également contacter un membre de {{asso_name}} pour renouveller ton adhésion

+{% else %} +

Pour renouveller ton adhésion, contacte un membre de {{asso_name}}.

+{% endif %} + +

A bientôt.

+ +
+ +

Hi {{nom}},

+ +

Your subscription comes to an end in {{temps}} days.

+ +{% if online %} +

You can renew it online on My Profil.

+

You can also contat a member of {{asso_name}} to renew it.

+{% else %} +

To renew it, contact a member of {{asso_name}}

+{% endif %} + +

Regards.