Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ Lancer avec `./run.sh` ou `python main.py`.
- **Historique** : 5 derniers pointages, détail par jour, heures semaine courante/précédente
- **Fermer** : `Escape`, clic sur X, ou `Ctrl+C` dans le terminal

## Documentation
### Globale
- Certaines procédures et / ou schéma sont disponible dans la KB ou sur Teams dans le Canal

### E2E

- Procédure complète, prérequis serveur et scénario de tests: [`tests/E2E/README.md`](tests/E2E/README.md)

## Dépendances

| Paquet | Usage |
Expand Down
117 changes: 105 additions & 12 deletions api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import hmac
import json
import urllib.error
import os
from functools import reduce
import sys
from enum import Enum
Expand All @@ -16,18 +17,19 @@ class Controller(Enum):
LOGS = 'LogsAPI'
BADGES = 'BadgesAPI'
USERS = 'UsersAPI'
EVENT_LOGS = 'EventLogsAPI'

class APIClient:
def __init__(self) -> None:
local_test = False
if local_test:
self.base_url = 'http://localhost:8080'
else:
self.base_url = 'https://timbreuse.sectioninformatique.ch'
self.base_url = os.getenv(
'TIMBREUSE_API_BASE_URL',
'https://timbreuse.sectioninformatique.ch'
).rstrip('/')

@staticmethod
def load_key() -> str:
with open('.key.json', 'r') as file:
key_path = os.getenv('TIMBREUSE_API_KEY_FILE', '.key.json')
with open(key_path, 'r', encoding='utf-8') as file:
return json.load(file)['key']

def create_token(self, date, badge_id, inside) -> str:
Expand Down Expand Up @@ -58,8 +60,38 @@ def send(self, url) -> tuple:
return html_file, html_file.status
except urllib.error.HTTPError as e:
return None, str(e)


except urllib.error.URLError as e:
return None, str(e)

@staticmethod
def _decode_response_body(html_file) -> str:
body = html_file.read()
if isinstance(body, bytes):
return body.decode('utf-8', errors='replace')
return str(body)

def _parse_json_response(self, html_file, url: str):
body = self._decode_response_body(html_file).strip()
if body == '':
return []
try:
return json.loads(body)
except json.JSONDecodeError:
# Certains endpoints peuvent prefixer la reponse par du bruit
# (warning PHP, HTML, saut de ligne, etc.). On tente d'extraire
# le premier JSON valide.
decoder = json.JSONDecoder()
for idx, ch in enumerate(body):
if ch not in ('{', '['):
continue
try:
parsed, _ = decoder.raw_decode(body[idx:])
return parsed
except json.JSONDecodeError:
continue
preview = body[:240].replace('\n', '\\n')
raise ValueError(f"Reponse API non-JSON sur {url}. Apercu: {preview}")

def send_log(self, date, badge_id, inside) -> tuple:
'''
>>> client_API = APIClient()
Expand Down Expand Up @@ -90,8 +122,9 @@ def receive_logs(self, start_date) -> list[dict]:
url = self.create_url_n(Controller.LOGS.value, Method.GET.value, arg)
print(url, file=sys.stderr)
html_file = self.send(url)[0]

return json.loads(html_file.readline())
if html_file is None:
return []
return self._parse_json_response(html_file, url)

def send_badge_and_user(self, badge_id:int, name:str, surname:str):
'''
Expand Down Expand Up @@ -123,7 +156,9 @@ def receive_users(self, start_date) -> list[dict]:
url = self.create_url_n(Controller.USERS.value, Method.GET.value, arg)
print(url, file=sys.stderr)
html_file = self.send(url)[0]
return json.loads(html_file.readline())
if html_file is None:
return []
return self._parse_json_response(html_file, url)

def receive_badges(self, start_date):
'''
Expand All @@ -140,7 +175,65 @@ def receive_badges(self, start_date):
url = self.create_url_n(Controller.BADGES.value, Method.GET.value, arg)
print(url, file=sys.stderr)
html_file = self.send(url)[0]
return json.loads(html_file.readline())
if html_file is None:
return []
return self._parse_json_response(html_file, url)

def receive_event_logs(self, start_date) -> list[dict]:
"""
Recupere les events serveur (ex: hard delete) depuis start_date.
"""
print('receive_event_logs', file=sys.stderr)
token = self.create_token_args(start_date)
arg = self.create_arg_args(start_date, token)
url = self.create_url_n(Controller.EVENT_LOGS.value, Method.GET.value, arg)
print(url, file=sys.stderr)
html_file = self.send(url)[0]
if html_file is None:
return []
return self._parse_json_response(html_file, url)

@staticmethod
def is_not_deleted(remote_row: dict) -> bool:
"""
Interprete le champ date_delete renvoye par l'API distante.
"""
date_delete = remote_row.get('date_delete')
if date_delete is None:
return True
if isinstance(date_delete, str):
return date_delete.strip().lower() in ('', 'none', 'null')
return False

def remote_user_exists_for_badge(self, badge_id: int) -> bool:
"""
Verifie en interrogeant l'API distante si le badge est encore
attribue a un utilisateur actif.
"""
try:
start_date = '1900-01-01 00:00:00'
users = self.receive_users(start_date)
badges = self.receive_badges(start_date)
except Exception as e:
# En cas d'indisponibilite reseau/API, on n'interrompt pas le
# pointage local.
print('remote_user_exists_for_badge fallback:', e, file=sys.stderr)
return True

active_user_ids = set()
for user in users:
if self.is_not_deleted(user):
active_user_ids.add(user.get('id_user'))

for badge in badges:
if badge.get('id_badge') != badge_id:
continue
if not self.is_not_deleted(badge):
continue
id_user = badge.get('id_user')
if id_user is not None and id_user in active_user_ids:
return True
return False


@staticmethod
Expand Down
31 changes: 31 additions & 0 deletions docs/DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,34 @@ Le fichier `sql/test_data.sql` contient :
- 3 utilisateurs (Dupont Jean, Martin Sophie, Müller Hans)
- 3 badges (dont le badge 63 utilisé par `fake_rfid.py`)
- Des logs sur 2 semaines pour tester l'affichage des heures

## Complément E2E (tests bout en bout)

Le scénario décrit dans `tests/E2E/README.md` valide un flux client + serveur sur les suppressions distantes (soft/hard delete).

### Tables impliquées dans la validation E2E

- **Côté serveur (`timbreuse-srv`)**
- `event_type` : journal d'événements métier, incluant les événements `hard_delete`.
- `user_sync`, `badge_sync`, `log_sync` : source de vérité distante (avec soft delete via `date_delete`).
- **Côté client (`Timbreuse`)**
- `event_log_sync` : réception des événements serveur, avec un flag `processed` pour confirmer l'application locale.
- `user_sync`, `badge_sync`, `log_sync` : miroir local synchronisé depuis le serveur.
- `*_write` : données locales en attente de synchronisation.

### Règles de suppression vérifiées

- **Soft delete** : la ligne reste présente, `date_delete` est renseigné.
- **Hard delete** : la ligne est physiquement supprimée côté serveur et un événement `hard_delete` doit être émis.
- **Attendu côté client** : l'événement est importé dans `event_log_sync`, appliqué idempotemment, puis marqué `processed=1`.

### Validation SQL minimale recommandée (E2E)

Les snapshots SQL du scénario E2E sont consignés dans `tests/E2E/logs/E2E_logs.txt`.
Vérifier au minimum :

- suppression effective du badge ciblé (hard delete),
- état d'attribution du badge réaffecté,
- présence/état de soft delete de l'utilisateur concerné,
- présence d'un événement `hard_delete` côté serveur (`event_type`),
- réception et traitement côté client (`event_log_sync.processed=1`).
2 changes: 1 addition & 1 deletion fake_rfid.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ def read_pipe(self, pipe: dict) -> None:
put a fake id in a dict in arg
'''
sleep(1)
pipe['id_badge'] = 63
pipe['id_badge'] = 42
print('fake_badge', file=sys.stderr)
39 changes: 39 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import fake_rfid as rfid
import model
import sys
import time

class App:
'''
Expand Down Expand Up @@ -38,6 +39,8 @@ def __init__(self):
self.thread_receive_log = None
self.thread_receive_users = None
self.thread_receive_badges = None
self.thread_receive_event_logs = None
self.thread_apply_event_logs = None
self.thread_delete_badges_and_users = None
self.thread_synchronize_user_badge_log_with_remote = None
self.thread_Invoke_synchronize_before_rfid = None
Expand Down Expand Up @@ -65,6 +68,10 @@ def update(self):
self.invoke_thread('thread_Invoke_synchronize_before_rfid',
self.Invoke_synchronize_before_rfid)
self.do_rfid()
if self.HAS_REMOTE_SERVER:
if self.handle_deleted_remote_badge():
self.reset()
return
if self.HAS_REMOTE_SERVER:
self.invoke_thread('thread_Invoke_synchronize_after_rfid',
self.Invoke_synchronize_after_rfid)
Expand Down Expand Up @@ -239,6 +246,34 @@ def do_model_request(self):
self.model.find_user_info, args=(self.pipe, ))
print('end do_model_request', self.pipe, file=sys.stderr)

def wait_modal_ack(self):
while (not self.pipe.get('quit', False)
and self.view is not None
and self.view.current_scene == 'modal'):
time.sleep(0.1)

def handle_deleted_remote_badge(self) -> bool:
"""
Retourne True si une suppression distante est detectee et geree.
"""
check = self.model.check_badge_assignment_with_remote(self.pipe['id_badge'])
if check['remote_exists']:
return False

if check['local_exists']:
self.model.remove_local_badge_correspondance(self.pipe['id_badge'])
user_name = check['local_user_name'] or 'cet utilisateur'
texts = [
f"Ce badge n'est plus attribue a {user_name}.",
'Cette correspondance est supprimee egalement sur cet appareil.',
]
else:
texts = ["Ce badge n'est pas attribue a un utilisateur."]

self.view.do_badge_sync_error(texts)
self.wait_modal_ack()
return True

def safe_is_alive(self, thread):
try:
print('tread is alive', thread.is_alive(), file=sys.stderr)
Expand Down Expand Up @@ -290,6 +325,10 @@ def is_unknown(self):

def get_threads_and_functions_list(self):
threads_and_functions = list()
threads_and_functions.append(('thread_receive_event_logs',
self.model.invoke_receive_event_logs))
threads_and_functions.append(('thread_apply_event_logs',
self.model.apply_event_logs))
threads_and_functions.append(('thread_send_badges_and_users',
self.model.send_unsync_badges_and_users))
threads_and_functions.append(('thread_send_log', self.model.send_logs))
Expand Down
Loading