Code source du compteur des lève-tôt revu et corrigé

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# ========================================================================================
#
# Script revu et modifié par Thomas Bouchet et ljere
# auteur originel Gabriel Pettier
# license GPL V3 or later
#
# Mise sur plusieurs lignes des commandes et ajout de commentaires
# Modification utilisée pour fonctionnement sur le Topic des Lève-Tôt
#
# Description :
# Ce script permet de comptabiliser les points engrangés par les utilisateurs sur un sujet
# d'un forum suivant l'heure à laquelle ils laissent un message.
# Les résultats sont affichés régulièrement sur le sujet par un compte spécifique appelé
# "bot" (qu'il faut donc inscrire à la main pour le bon fonctionnement du script).
# Les points des différents utilisateurs sont conservés dans un fichier nommé "count"
# et "count10days"
#
# ========================================================================================



# -----------------------------------------------
# Ajout de bibliothèques utiles
# -----------------------------------------------

# BeautifulSoup est une librairie permettant d'analyser un arbre (du HTML ici)
from BeautifulSoup import BeautifulSoup
# urllib2 est une librairie permettant d'ouvrir des URLs simplement (plutôt en HTTP ici)
import urllib2
# re est une librairie permettant d'utiliser les expressions rationnelles (regular expressions)
import re
# time est une librairie de fonctions liées à la manipulation des dates
import time
# sys est une librairie permettant d'utiliser les fonctionnalités de base du langage python
import sys
# mechanize est une librairie permettant de gérer des objets HTML comme les formulaires
import mechanize

# -----------------------------------------------
# Initialisations
# -----------------------------------------------

# Ceci est une liste des utilisateurs à ne pas prendre en compte. On y mettra notamment le nom de
# l'utilisateur utilisé par le "bot"
ignoreList = (
    'compteur des leve tot',
    )


# -----------------------------------------------
# Class Day
#
# Cette classe permet de manipuler les utilisateurs et leurs points par jour. La classe contient
# donc les points par joueur du jour actuel.
#
# Pour des raisons propres au sujet qui nous intéresse (compteur des lève-tôt), la comptabilisation
# des points se fait entre 5 heures et 9 heures du matin exclu ([5h;9h[). Le reste ne sera donc pas
# pris en compte.
#
# Attributs :
#     - entries : le tableau contenant les points de chaque joueur
#
# Méthodes spécifiques :
#     - addEntry : Fonction permettant l'ajout d'un enregistrement dans le tableau des joueurs
#                  Cette fonction permet aussi la mise à jour du nombre de points d'un joueur déjà existant
#
# -----------------------------------------------
class Day:
    # Description de la classe
    """Contient la dernière entrée (points) de ce jour pour chaque joueur"""

    # Cette fonction initialise le tableau des utilisateurs lors de l'instanciation de la classe
    def __init__(self):
        self.entries={}

    # Cette fonction permet d'ajouter un utilisateur et de le lier à son nombre de points
    # On utilise : entries['utilisateur'] = nb_points
    def addEntry(self, entry):
        # Si l'utilisateur existe déjà, on lui attribue le nouveau nombre de moins à condition
        # que celui-ci soit supérieur à l'ancien nombre de points.
        # Pour des raisons de légèreté du code, il est plus simple de faire un try/except que de vérifier
        # que l'entrée existe
        try:
            self.entries[entry.name] = max(self.entries[entry.name],entry.date.points())
        except:
            self.entries[entry.name] = entry.date.points()

    # Cette fonction permet d'afficher la classe sous forme de chaîne de caractères.
    # Ici, on affiche la liste de chaque utilisateur suivi de son nombre de points
    def __str__(self):
        for entry in self.entries.items():
            print entry,'+',entries[entry]



# -----------------------------------------------
# Fonction utcFrance
#
# Cette fonction renvoie le décalage horaire a appliquer.
#
# Dans le cas spécifique qui nous préoccupe, le script est utilisé en France, un simple +1 suffit
# alors à déterminer le décalage horaire, sachant que le résultat renvoyé sera 1 ou 2 suivant si la machine hébergeant
# le script est en heures d'été ou pas.
#
# -----------------------------------------------
def utcFrance():
    #1 + 1 si on est a l'heure d'été
    return 1 + time.localtime(time.time())[-1]


# -----------------------------------------------
# Class Date
#
# Cette classe permet de calculer le nombre de points qu'un utilisateur devra recevoir suivant l'heure
# à laquelle il a écrit son message.
#
# Pour des raisons propres au sujet qui nous intéresse, un utilisateur écrivant entre :
# 5h00 et 5h59 recevra 10 points
# 6h00 et 6h59 recevra 6 points
# 7h00 et 7h59 recevra 3 points
# 8h00 et 8h59 recevra 1 point
#
# Attributs :
#     - h : entier représentant une heure
#     - m : entier représentant les minutes
#
# Méthodes spécifiques :
#     - points : qui renvoie le nombre de points à donner pour une heure spécifique
#
# -----------------------------------------------
class Date:
    # Description de la classe

    # Cette fonction initialise deux entiers de la classe : h et m (hu hu hu, quel jeu de mot !)
    # Si aucune donnée n'est fournie, h et m sont initialisés avec 20 et 0, et utc vaut utcFrance()
    def __init__(self,tuple=(20,0),utc=utcFrance()):
        # h est initialisé suivant l'heure donnée et le décalage horaire
        self.h = (int(tuple[0])-utcFrance()+24+utc)%24
        # m est initialisé suivant la minute donnée
        self.m = int(tuple[1])

    # Cette fonction permet de comparer l'objet à un autre objet équivalent
    # Ici, la comparaison se fait sur le nombre de points entre les deux classes
    def __cmp__(self, other):
        return cmp(self.points(),other.points())

    # Cette fonction permet de connaître le nombre de points à donner à un utilisateur suivant une heure spécifiée
    # Si l'heure correspondante n'est pas trouvée, la fonction retourne 0
    def points(self):
        # Instanciation d'un dictionnaire (clé: valeur)
        pts =  {5: 10, 6: 6, 7: 3, 8: 1}
        # Si la clé n'est pas trouvée, retourne 0
        return pts.get(self.h, 0)


# -----------------------------------------------
# Class Entry
#
# Cette classe permet de lister les méta-données nécessaires de chaque message afin de calculer les points
# des utilisateurs.
#
# Pour des raisons propres au sujet qui nous intéresse
#
# Attributs :
#     - name : Nom de l'utilisateur ayant laissé un message sur le forum
#     - date : Date du message laissé par l'utilisateur
#
# Méthodes spécifiques :
#     - setName : permet de spécifier un nom d'utilisateur
#     - setDate : permet de spécifier une date
#
# -----------------------------------------------
class Entry:
    # Description de la classe

    # Cette fonction initialise les attributs de la classe suivant les données fournies
    def __init__(self,name='',date=Date(),edit=Date()):
        self.name = name
        #self.date = max(date,edit)
        # Contrairement au topic des couche-tard, l'édition d'un message n'est pas pris en compte pour le topic
        # des lève-tôt
        self.date = date

    # Cette fonction permet de spécifier un nom d'utilisateur après l'instanciation de la classe
    def setName(self, name):
        self.name = name

    # Cette fonction permet de spécifier une date après l'instanciation de la classe
    def setDate(self, date):
        if date.points()>self.date.points(): self.date = date


# -----------------------------------------------
# Class Score
#
# Cette classe permet de lister le classement des utilisateurs.
#
# Attributs :
#     - name : Nom de l'utilisateur ayant laissé un message sur le forum
#     - num : Score de l'utilisateur
#
# Méthodes spécifiques :
#
# -----------------------------------------------
class Score:
    # Description de la classe

    # Cette fonction initialise les attributs de la classe suivant les données fournies
    def __init__(self, tuple):
        self.name = tuple[1]
        self.num = int(tuple[0])

    # Cette fonction permet de vérifier si l'objet est plus grand qu'un autre objet équivalent
    # Ici, la comparaison se fait sur num (le score de l'utilisateur)
    def __gt__(self, other):
        return self.num>other.num

    # Cette fonction permet d'afficher la classe sous forme de chaîne de caractères.
    # Ici, cela permet d'afficher le score de l'utilisateur suivi de son nom
    def __str__(self):
        return '%i    %s' %(self.num, self.name)


# -----------------------------------------------
# Fonction getPage
#
# Cette fonction récupère la page de la discussion spécifique du forum et comptabilise les points
# des utilisateurs qui ont écrits dessus
#
# Dans le cas spécifique qui nous préoccupe, tout le code de la fonction est très spécifique
# au forum et aux css utilisés (ici, ubuntu-fr.org)
#
# -----------------------------------------------
def getPage(url, entries, stat, urlscore):
    # Essaye jusqu'a 15 fois de récupérer la page
    for i in range(15):
        try:
            # page contiendra le contenu de l'url recherchée
            page = BeautifulSoup(urllib2.urlopen(url))
            break
        except:
            print 'essai: %s' % i
            # Lors de la dernière tentative, la main est rendue au gestionnaire d'erreur principal
            if i==14: raise
            # A chaque tentative non réussie, un délai de 60 secondes est attendu pour ne pas submerger le serveur
            time.sleep(60)

    # Le print permet de tracer le travail du script à condition de lancer le script à la main ou bien
    # de rediriger la sortie du script vers un fichier lorsqu'on utilise un cron pour l'exécuter
    print 'page récupéré, travail en cours'

    # ============ ATTENTION ==================
    # A partir de là, le code est très spécifique au contenu HTML des pages du forum

    # Parcours de chaque élément DIV de la page contenant un message
    for post in page.findAll("div","blockpost rowodd blockpost1")+page.findAll("div","blockpost roweven")+page.findAll("div","blockpost rowodd"):
    # post contient donc le contenu du DIV complet

        # Récupération du contenu du lien vers le message
        str_date = str(post.find("h2").find("span").find("a"))

        # Découpage du lien afin de supprimer le tag ancre

        # On conserve la partie située après le premier caractère ">"
        str_date = str_date.split('>')[1]
        # On conserve la partie située avant le premier caractère "<"
        str_date = str_date.split('<')[0]

        # str_date contient alors quelque chose comme l'un des trois exemples ci-dessous:
        # Le XX/XX/XXXX, à XX:XX
        # Hier à XX:XX
        # Aujourd'hui à XX:XX

        # hh_mm est un tableau qui contient l'heure et la minute du message
        hh_mm = str_date.split(' ')[2].split(':')

        # Si str_date contient Aujourd'hui et que l'heure du message est située entre 5 et 9
        if (str_date.split(' ')[0] in ["Aujourd\'hui"]
             and int(hh_mm[0]) in range(5,9)
           ):

            # On a trouvé au moins un message qui contient un score, on stocke donc l'url
            urlscore = url

            # Récupération du nom de l'utilisateur qui a écrit le message et création d'une
            # instance de la classe Entry avec le nom récupéré
            # Le nom de l'utilisateur est soit situé dans un lien (s'il a un profil), soit directement
            # dans le tag strong (s'il n'a pas de profil)
            try:
                entry = Entry(str(post.find("div","postleft").find("a")).split(">")[1].split("<")[0])
            except:
                entry = Entry(str(post.find("div","postleft").find("strong")).split(">")[1].split("<")[0])

            # Spécification du décalage horaire par défaut du message
            utc = utcFrance()

            # Si la chaine GMT est trouvée dans le message de l'utilisateur, on récupère le décalage horaire
            # et on l'applique à la variable utc
            if 'GMT' in str(post):
                try:
                    utc = int(str(post).split("GMT")[-1].split(" ")[0].split("<")[0])
                    print 'GMT found',utc
                except:
                    print "no good GMT!"

            # La date de l'instance de la classe Entry est alors spécifiée en donnant un tableau de 2 entiers
            # correspondant à l'heure et à la minute du message et le décalage horaire
            entry.setDate(Date(hh_mm,utc))

            # On note le nombre de messages ayant obtenu des points par heure pour effectuer des statistiques
            if hh_mm[0] not in stat:
                stat[hh_mm[0]] = 1
            else:
                stat[hh_mm[0]] += 1

            # L'entrée est alors ajoutée à la liste des entrées uniquement si l'utilisateur ne doit pas être ignoré
            # et si les points associés à l'entrée sont supérieurs à 0
            if entry.name not in ignoreList and (entry.date.points() is not 0): entries.addEntry(entry)

        # Fin du if
    # Fin du parcours des messages


    # Ici, on vérifie si l'on relance le script pour atteindre la page suivante, car les messages peuvent être dispersés sur plusieurs
    # pages. On le fera si result vaut True, ce qui arrive uniquement si on a plusieurs pages et que la page sur laquelle on se situe
    # n'est pas la dernière de la discussion

    # S'il n'y a qu'une seule page dans la discussion, result vaut False
    if str(page.find('p','pagelink conl')).split('conl">')[1].split('</p')[0].split(str(page.find('p','pagelink conl').find('strong'))) == ['', '']:
        result = False
    else:
        # Sinon, on renvoie True si le numéro de la dernière page est plus grand que la page sur laquelle on est
        try:
            result = int(url.split('p=')[1]) < int(str(page.find('p','pagelink conl').findAll('a')[-2]).split('p=')[1].split('"')[0])
        except IndexError:
            result = False

    # Si on est arrivé au bout de la discussion, il faut, en plus, vérifier que la discussion ne continue pas sur une autre discussion
    # Ce procédé est utilisé sur le forum quand une discussion atteint trop de pages pour éclaircir le forum
    if not result:

        # Si la page contient la chaine "Discussion fermée" dans un paragraphe spécifique
        if "Discussion fermée" in ''.join( (str( i) for i in page.findAll('p','postlink conr'))):

            # Récupération du lien vers la nouvelle discussion
            result = str(page.findAll('div','postmsg')[-1].findAll('a')[-1]).split('"')[1]

    # Finalement, la fonction renvoie result et urlscore
    return result, urlscore

# -----------------------------------------------
# Fonction renderstats
#
# Cette fonction crée deux graphiques permettant de représenter les répartitions de messages par heure pour la journée
#
# -----------------------------------------------
def renderstats(stats):
    # Si le dictionnaire stats contient quelque chose
    if stats != {}:

        # Création d'un dictionnaire pour la journée
        DayStats = {'05': 0, '06': 0, '07': 0, '08': 0, '09': 0}
        # Copie des valeurs des clés existantes ce qui permet d'avoir un tableau contenant toutes les heures
        DayStats.update(stats)

        # ============ ATTENTION ==================
        # Le reste de cette fonction n'est pas commenté car elle ne sert qu'à des fins statistiques et que j'ai autre chose à faire ;oP
        HoursBar = 'h|'.join(sorted(DayStats.keys()))+'h'
        HoursBar = HoursBar[20:]+'|'+HoursBar[:19]
        HoursBar = HoursBar[0:len(HoursBar)-1]
        HoursPie = 'h|'.join(sorted(stats.keys()))+'h'
        for k in stats.keys():
            HoursPie = HoursPie.replace(k+'h', k+'h%20-%20'+k+'h59')

        DataBar = ','.join([str(DayStats[x]) for x in sorted(DayStats.keys())])
        DataBar = ','.join(DataBar.split(',')[5:7])+','+','.join(DataBar.split(',')[7:9])
        Vmax10 = str(10*(int(max([DayStats[x] for x in DayStats.keys()]))/10+1))

        urlimage='[img=Répartition]http://chart.apis.google.com/chart?chs=675x280&cht=p3&chco=d80020,d88000,ffd840,20d820,2080ff,101080,a020d8&chf=bg,s,00000000&chl='+HoursPie+'&chd=t:'+','.join([str(stats[x]) for x in sorted(stats.keys())])+'&chp=1.6&chtt=R%C3%A9partition%20des%20posts&chts=606060,16[/img]'

        urlimage+='[img=Posts/heure]http://chart.apis.google.com/chart?chs=675x280&cht=bvs&chxt=x,y&chds=0,'+Vmax10+'&chxr=1,0,'+Vmax10+((Vmax10 == '30' and ',5') or '')+'&chf=b0,lg,0,803000,0,ffc080,1|bg,s,00000000&chxl=0:|'+HoursBar+'h'+'&chxp=0,0.7,9.1,17.3,25.6,33.9,42&chd=t:'+DataBar+'&chm=N,803000,0,-1,12&chtt=|Nombre%20de%20posts%20par%20heure&chts=606060,16[/img]'

        return urlimage

    return None



# -----------------------------------------------
# Fonction post
#
# Cette fonction permet au "bot" d'écrire un message qui récapitule les scores des utilisateurs
#
# Dans le cas spécifique qui nous préoccupe, le fichier '.compteur_logins' doit contenir le login du posteur sur la première ligne,
# et son mot de passe sur la deuxième (cela et seulement cela).
#
# -----------------------------------------------
def post(_file, stats):
    # Récupération de l'identifiant et du mot de passe du "bot" qui va lire, analyser, puis poster le résultat dans la discussion
    file = open(".compteur_logins","r")
    login = file.readline().split('\n')[0]
    password = file.readline().split('\n')[0]
    file.close()

    # Instanciation d'un Cookie géré par la librairie mechanize
    cookieJar = mechanize.CookieJar()

    # Création d'un navigateur spécifique pour le "bot" et liaison avec le cookie
    # Désormais, les différents appels de pages webs se feront en appui des informations de session
    # conservées dans le Cookie
    opener = mechanize.build_opener(mechanize.HTTPCookieProcessor(cookieJar))
    opener.addheaders = [("User-agent","Mozilla/5.0 (compatible)")]
    mechanize.install_opener(opener)

    # Appel du formulaire de connexion
    fp = mechanize.urlopen("http://forum.ubuntu-fr.org/login.php")
    forms = mechanize.ParseResponse(fp)
    fp.close()

    # Remplissage du formulaire de connexion et validation de celui-ci
    form = forms[1]
    form["req_username"] = login
    form["req_password"] = password
    fp = mechanize.urlopen(form.click())
    fp.close()

    # ouverture du fichier url contenant l'adresse de la discussion à analyser
    file = open('url','r')
    tid = file.readline().split('=')[1][:-2] # la première ligne contenant l'addresse du topic.
    file.close()

    # Ouverture du formulaire permettant d'ajouter un message dans une discussion
    fp = mechanize.urlopen("http://forum.ubuntu-fr.org/post.php?tid="+tid)
    forms = mechanize.ParseResponse(fp)
    fp.close()

    # Trace des informations utilisées
    print "http://forum.ubuntu-fr.org/post.php?tid="+tid
    print forms[0]

    form = forms[1]

    # Préparation du message : Titre du message suivant l'action demandée à l'appel de la fonction
    # via son paramètre _file
    title = (((_file == "count") and "Scores totaux, depuis le début") or "scores de la période en cours")

    # Préparation du message : Ajout de la balise [code]
    # Les codes hexadécimaux utilisés à la suite de la balise code servent surement à quelque chose
    form["req_message"]  = title+" :[code]"

    # Récupération des scores enregistrés dans le fichier correspondant
    file = open(_file, 'r')
    scores=file.readlines()

    # Création des images statistiques si demandées
    urlimage = renderstats(stats)
    stats = {}

    # Pour chaque score lu à partir du fichier
    for i in range(len(scores)):
        # La variable tmpRange contient le classement des utilisateurs par rapport à leur score
        # Pour la première ligne, on initialise donc tmpRange à 0
        if i == 0:
            tmpRange = 0
        # Si le score déjà lu est le même que celui qu'on vient de lire, la position du classement reste la même
        elif scores[i].split(" ")[0] == scores[i-1].split(" ")[0]:
            pass
        # Sinon, on augmente la place dans le classement
        else:
            tmpRange = i

        # Préparation du message : Ajout du classement, du score, et du nom de l'utilisateur
        # C'est ici que l'on peut gérer les noms spécifiques et/ou les annonces spécifiques pour un
        # utilisateur
        form["req_message"] += (str(tmpRange+1)+") "+scores[i])

    # Le message contient donc tous les scores, il est conclut en fermant la balise [code]
    # Si les statistiques sont demandées, les images correspondantes sont rajoutées après
    form["req_message"] += "[/code]"+(urlimage or '')

    # Validation du formulaire
    fp = mechanize.urlopen(form.click())
    fp.close()


# -----------------------------------------------
# Fonction main
#
# Cette fonction est la fonction principale qui gère tout le reste
#
# Dans le cas spécifique qui nous préoccupe,
#
# -----------------------------------------------
def main(urlfile, files):
    # Pour debugger le script ou pas
    debug = False
    # Initialisation du tableau des statistiques
    stats = {}

    # Ouverture du fichier contenant l'url de la discussion à analyser
    f=open(urlfile,"r")

    # Stockage du numéro de la discusion (topic id : tid)
    url=urlscore=f.readline().split('\n')[0]
    f.close()

    # Instanciation de la classe Day
    entries = Day()

    # Boucle infinie permettant de comptabiliser les scores des utilisateurs
    while True:

        # Trace
        print "lecture de la page "+url

        # Premier comptage des points sur la dernière page visitée (la dernière fois que le script a été lancé)
        res = getPage(url, entries, stats, urlscore)

        # Récupération de l'url à réutiliser la prochaine fois
        urlscore = res[1]

        # Si la fin de discussion a été trouvée (result de getPAge vaut False), sortie de la boucle
        if not res[0]: break

        # Sinon, on recherche l'url de la même discussion mais page suivante à analyser
        url=url.split('p=')[0]+'p='+str(1+int(url.split('p=')[1]))
        # Ou bien, on repart sur l'url donnée d'une nouvelle discussion
        if res[0] is not True:
            url = url.split('?')[0]+'?'+res[0].split('?')[1]+'&p=1'

    # Sauvegarde l'url utilisée en dernier (nouvelle page ou discussion) pour la prochaine fois
    if not debug:
        f=open(urlfile,"w")
        f.write(urlscore+'\n')
        f.close()

    # Pour chaque type de travail à faire
    for file in files:

        # Ouverture du fichier correspondant
        f=open(file,'r')

        # Lecture des lignes du fichier
        # Pour l'action 10days, le 2 du mois tout est remis à zéro (ce qui fait plus 1month que 10days d'ailleurs)
        lines=(file=="count10days" and ((time.localtime()[2]==2 and ["0    "+entries.entries.keys()[0]+"\n"]) or f.readlines()) or f.readlines())
        f.close()

        # Expression régulière permettant de récupérer le score, puis le nom séparés par un nombre quelconque d'espaces
        exp = re.compile("^[0-9]+\s*")

        # Initialisation de la liste des scores
        scores = []
        print "lecture scores courants"
        # Remplissage du tableau des scores ligne par ligne
        for line in lines:
            # Si la ligne n'est pas vide
            if line not in [' ','']:
               print line
               # Création d'une instance de la classe Score aussitôt stockée dans le tableau scores
               # et contenant le score, suivi du nom
               scores.append(Score([(line.split(' ')[0]),exp.split(line)[1].split('\n')[0]]))

        # Initialisation des nouveaux scores
        new_scores=[]
        # Remplissage du tableau des nouveaux scores et mise à jour des anciens scores suivant la liste des utilisateurs
        # ayant gagné des points
        for entry,num in entries.entries.items():
            # Sous-boucle permettant de chercher si un utilisateur existe déjà
            for score in scores:
                # Comparaison des noms d'utilisateurs en casse basse
                if entry.lower() == score.name.lower():
                    # L'utilisateur existant, on ajoute son ancien score au nouveau
                    score.num+=num
                    # Puis on quitte la sous-boucle
                    break
                if score is scores[-1]:
                    # L'utilisateur n'existe pas encore, on rajoute donc son score au tableau des nouveaux scores
                    new_scores.append(Score([num,entry]))
                    break

        # Les deux tableaux sont joints afin de contenir tous les scores
        scores+=new_scores

        # Vérification des doublons
        for nScore in range(len(scores)-1):
            for mScore in range(nScore+1,len(scores)-1):
                try:
                    # En cas de doublon
                    if scores[nScore].name.lower() == scores[mScore].name.lower():
                        # les scores sont sommés
                        scores[nScore].num+=scores[mScore].num
                        # puis le doublon est détruit
                        del(scores[mScore])
                except:
                    pass

        # Tri des scores du plus grand au plus petit
        scores.sort(reverse=True)

        # Sauvegarde des scores dans le fichier correspondant
        if not debug:
            # Trace
            for score in scores: print score

            # Ecriture des scores
            f=open(file, "w")
            for score in scores:
                f.write('%s\n'%score)
            f.close()

            # Essai 15 fois maximum d'écrire le message des scores
            for i in range(15):
                try:
                    post(file, stats)
                    stats = {}
                    break
                except:
                    if i == 14: raise
                    time.sleep(60)
        time.sleep(10)

# Lancement de la fonction principale
main("url",["count","count10days"])

# Action à lancer pour ne faire qu'une seule fonction à la main (en général pour le debug)
#post("count",{})
#post("count10days",{})
compteur_levetot.txt · Dernière modification: 2012-06-29 10:38 par ljere
 
Sauf mention contraire, le contenu de ce wiki est placé sous la licence suivante : GNU Free Documentation License 1.3
Recent changes RSS feed Donate Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki