From eff874075f4f6f6dbf713b6a55a6868183731bbc Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Mon, 11 May 2026 14:51:22 +0200 Subject: [PATCH 1/5] Assignment page access --- src/utils/nav.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/nav.py b/src/utils/nav.py index a34850f..fdf362b 100644 --- a/src/utils/nav.py +++ b/src/utils/nav.py @@ -19,7 +19,7 @@ PAGE_ROLES = { "manage_projects": ["secretary", "teacher"], "project_detail": ["program director", "secretary", "teacher", "student"], - "assigned_project": ["student"] + "assigned_project": ["student", "program director"] } def allowed(roles: list[Role], allowed_roles: list[str]): From 1118b7e725ec2950349e43509dfb3b929689b0d8 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Mon, 11 May 2026 14:52:29 +0200 Subject: [PATCH 2/5] Logger singleton --- .gitignore | 1 + src/utils/logger.py | 126 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/utils/logger.py diff --git a/.gitignore b/.gitignore index 4f802ef..5b31a09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ certs/ src/**/__pycache__ src/specs/* src/.streamlit/secrets.toml +src/logs/ diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..1efd6a9 --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,126 @@ +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from zoneinfo import ZoneInfo + + +class LogType(Enum): + INFO = "info" + WARN = "warn" + ERROR = "error" + + +@dataclass +class LogRecord: + uuid: str + type: LogType + timestamp: str + message: str + + +DEFAULT_LOG_PATH = Path("./logs") +LOG_FILE_NAME = "latest.log" + +TYPE_CHAR_MAP = { + LogType.INFO: "I", + LogType.WARN: "W", + LogType.ERROR: "E", +} + + +class Logger: + _instance: "Logger | None" = None + _initialized: bool = False + + def __new__(cls) -> "Logger": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + if Logger._initialized: + return + + self.log_path = DEFAULT_LOG_PATH + self.log_file_path = self.log_path / LOG_FILE_NAME + + Logger._initialized = True + + def is_initialized(self) -> bool: + return self._initialized + + def init(self) -> None: + self.log_path.mkdir(parents=True, exist_ok=True) + self._rename_latest() + + def _parse_timestamp_from_line(self, line: str) -> str | None: + try: + parts = line.split("]", 1) + if len(parts) < 2: + return None + ts_part = parts[1].split("|", 1)[0].strip() + return ts_part.split(" (")[0] + except (IndexError, ValueError): + return None + + def _rename_latest(self) -> None: + latest = self.log_file_path + + if latest.exists(): + with open(latest, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + + if first_line: + ts = self._parse_timestamp_from_line(first_line) + if ts: + safe_ts = ts.replace(":", "-").replace(".", "-") + archive_name = f"{safe_ts}.log" + latest.rename(self.log_path / archive_name) + + def _format_timestamp(self, iso_timestamp: str) -> str: + dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) + dt_cet = dt.astimezone(ZoneInfo("Europe/Paris")) + return dt_cet.strftime("%d-%m-%Y %H:%M:%S (%Z)") + + def _build_record(self, log_type: LogType, message: str) -> LogRecord: + return LogRecord( + uuid=str(uuid.uuid4()), + type=log_type, + timestamp=datetime.now(timezone.utc).isoformat(), + message=message, + ) + + def _write_record(self, record: LogRecord) -> None: + type_char = TYPE_CHAR_MAP[record.type] + formatted_ts = self._format_timestamp(record.timestamp) + log_line = f"[{type_char}] {formatted_ts} | {record.message}" + + with open(self.log_file_path, "a", encoding="utf-8") as f: + f.write(log_line + "\n") + + print(log_line) + + def info(self, message: str) -> None: + record = self._build_record(LogType.INFO, message) + self._write_record(record) + + def warn(self, message: str) -> None: + record = self._build_record(LogType.WARN, message) + self._write_record(record) + + def error(self, message: str) -> None: + record = self._build_record(LogType.ERROR, message) + self._write_record(record) + + +_logger = Logger() +_logger.init() + + +def get_logger() -> Logger: + return _logger + + +logger = _logger From 7babdb04bd010ed6b95b05fcc64947a8e3b738bd Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Mon, 11 May 2026 14:59:57 +0200 Subject: [PATCH 3/5] Logger tests --- src/tests/unit/test_logger.py | 97 +++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/tests/unit/test_logger.py diff --git a/src/tests/unit/test_logger.py b/src/tests/unit/test_logger.py new file mode 100644 index 0000000..d3cd131 --- /dev/null +++ b/src/tests/unit/test_logger.py @@ -0,0 +1,97 @@ +import sys +from unittest.mock import MagicMock + +sys.modules["streamlit"] = MagicMock() + +import pytest +from unittest.mock import patch, MagicMock +from importlib import reload + +from utils.logger import Logger + + +class TestLoggerSingleton: + """Tests for the Logger singleton pattern.""" + + @pytest.fixture(autouse=True) + def reset_singleton(self): + """Reset singleton instance before each test.""" + Logger._instance = None + Logger._initialized = False + yield + Logger._instance = None + Logger._initialized = False + + def test_singleton_returns_same_instance(self): + """Multiple Logger() calls return the same instance.""" + l1 = Logger() + l2 = Logger() + assert l1 is l2 + + def test_is_initialized_returns_true(self): + """is_initialized returns True after init.""" + l = Logger() + assert l.is_initialized() is True + + +class TestLoggerMethods: + """Tests for Logger public methods.""" + + @pytest.fixture(autouse=True) + def reset_singleton(self): + """Reset singleton instance before each test.""" + Logger._instance = None + Logger._initialized = False + yield + + @patch("utils.logger.open", new_callable=MagicMock) + def test_info_writes_to_file(self, mock_open): + """info() writes a log line to file.""" + l = Logger() + l.init() + l.info("test message") + + mock_open.assert_called() + mock_open.return_value.__enter__.return_value.write.assert_called() + + @patch("utils.logger.open", new_callable=MagicMock) + def test_warn_writes_to_file(self, mock_open): + """warn() writes a log line to file.""" + l = Logger() + l.init() + l.warn("warning message") + + mock_open.assert_called() + + @patch("utils.logger.open", new_callable=MagicMock) + def test_error_writes_to_file(self, mock_open): + """error() writes a log line to file.""" + l = Logger() + l.init() + l.error("error message") + + mock_open.assert_called() + + +class TestLoggerModuleExport: + """Tests for the module-level logger export.""" + + @pytest.fixture(autouse=True) + def reset_singleton(self): + """Reset singleton instance before each test.""" + import utils.logger as logger_module + Logger._instance = None + Logger._initialized = False + logger_module._logger = None + yield + Logger._instance = None + Logger._initialized = False + logger_module._logger = None + + def test_module_level_logger_is_singleton(self): + """The module-level logger export is the singleton instance.""" + import utils.logger as logger_module + reload(logger_module) + + assert logger_module.logger is logger_module._logger + assert logger_module.logger is logger_module.get_logger() \ No newline at end of file From 8d156cf8dd59a023a50b708d87e16de6e1826ab8 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Mon, 11 May 2026 15:16:16 +0200 Subject: [PATCH 4/5] Log messages --- src/endpoints/auth.py | 4 ++++ src/services/auth.py | 8 +++++++- src/services/ldap.py | 7 +++---- src/services/mail.py | 12 ++++++++---- src/utils/assignment.py | 4 ++++ src/utils/nav.py | 2 ++ src/views/login.py | 5 ++++- src/views/manage_projects.py | 5 +++++ src/views/project_detail.py | 4 ++++ 9 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/endpoints/auth.py b/src/endpoints/auth.py index 2a5fdac..1966c13 100644 --- a/src/endpoints/auth.py +++ b/src/endpoints/auth.py @@ -4,6 +4,7 @@ from starlette.responses import RedirectResponse from services.db import get_db +from utils.logger import logger async def create_session(request: Request): """Create a session from an auth token. @@ -19,6 +20,7 @@ async def create_session(request: Request): token = request.query_params.get("token") if not token: + logger.warn("Auth endpoint: missing token") return RedirectResponse("/") db = get_db() @@ -28,11 +30,13 @@ async def create_session(request: Request): auth_token is None or auth_token.expires_at < datetime.now(timezone.utc) ): + logger.warn("Auth endpoint: token expired or invalid") return RedirectResponse("/") db.remove(auth_token) session = db.create_session(auth_token.user_id) + logger.info(f"Session created for user_id: {auth_token.user_id}") response = RedirectResponse("/") response.set_cookie( diff --git a/src/services/auth.py b/src/services/auth.py index 5f119fb..d0b8382 100644 --- a/src/services/auth.py +++ b/src/services/auth.py @@ -6,6 +6,7 @@ from models.user import User from services.db import get_db from services.ldap import authenticate +from utils.logger import logger db = get_db() @@ -27,6 +28,7 @@ def logout(session: Session) -> None: """ db.remove(session) + logger.info(f"User logged out: {session.user.ldap_uid}") def login(uid: str, password: str) -> User|None: @@ -43,12 +45,15 @@ def login(uid: str, password: str) -> User|None: user_infos = authenticate(uid, password) if user_infos is None or user_infos["uid"] is None: + logger.warn(f"Login failed for user: {uid}") return None user = db.get_user(user_infos["uid"]) - + create_session(user) + logger.info(f"User logged in: {uid}") + return user def validate_session() -> Session|None: @@ -70,6 +75,7 @@ def validate_session() -> Session|None: return None if session.expires_at < datetime.now(timezone.utc): + logger.info(f"Session expired, removed for user: {session.user.ldap_uid}") db.remove(session) return None diff --git a/src/services/ldap.py b/src/services/ldap.py index facbf0c..7635245 100644 --- a/src/services/ldap.py +++ b/src/services/ldap.py @@ -1,6 +1,7 @@ from config import LDAP_BASE_DN, LDAP_HOST, LDAP_PASSWORD, LDAP_PORT, LDAP_USER from ldap3 import Entry, Server, Connection, ALL +from utils.logger import logger def _get_server(): return Server(LDAP_HOST, port=int(LDAP_PORT), use_ssl=True, get_info=ALL) @@ -27,8 +28,7 @@ def _search_user(uid: str, attributes: list[str]) -> Entry | None: conn.unbind() return entry except Exception as e: - print(e) - print("search bind error") + logger.error(f"LDAP search error: {e}") return None @@ -69,8 +69,7 @@ def authenticate(uid: str, password: str) -> dict[str, str|None] | None: return user_info except Exception as e: - print(e) - print("auth bind error") + logger.error(f"LDAP auth error for {uid}: {e}") return None diff --git a/src/services/mail.py b/src/services/mail.py index 07423f5..b2e8fdb 100644 --- a/src/services/mail.py +++ b/src/services/mail.py @@ -8,6 +8,7 @@ from jinja2 import Environment, FileSystemLoader from config import SMTP_PASSWORD, SMTP_SERVER, SMTP_PORT, SMTP_USERNAME +from utils.logger import logger from models.project import Project from models.user import User from services.db import Db, get_db @@ -307,8 +308,11 @@ def _send(self, mail: Mail) -> None: context = ssl.create_default_context() context.minimum_version = ssl.TLSVersion.TLSv1_3 - with smtplib.SMTP(self._server, self._port, timeout=10) as smtp: - smtp.starttls(context=context) - smtp.login(self._username, self._password) + try: + with smtplib.SMTP(self._server, self._port, timeout=10) as smtp: + smtp.starttls(context=context) + smtp.login(self._username, self._password) - smtp.sendmail(self._sender, all_recipents, msg.as_string()) + smtp.sendmail(self._sender, all_recipents, msg.as_string()) + except Exception as e: + logger.error(f"Failed to send email to {all_recipents}: {e}") diff --git a/src/utils/assignment.py b/src/utils/assignment.py index 63752a9..f896a15 100644 --- a/src/utils/assignment.py +++ b/src/utils/assignment.py @@ -9,6 +9,7 @@ from scipy.optimize import linear_sum_assignment from services.mail import Mailer +from utils.logger import logger def assignment_algorithm(project_ratings: Sequence[ProjectRating], student_ids: list[int], project_ids: list[int]) -> tuple[list[int], list[int]] : """Assignment algorithm. @@ -75,6 +76,7 @@ def assign_projects(program_id: int, project_ratings: Sequence[ProjectRating], d db.assign_project(project_id, student_id) + logger.info(f"Assignment complete for program {project_ratings[0].project.program.name}, emails sent") mailer.project_assignment(program_id) def remind_students(students: Sequence[User], n_projects: int, mailer: Mailer): @@ -94,7 +96,9 @@ def remind_students(students: Sequence[User], n_projects: int, mailer: Mailer): if len(student.project_ratings) != n_projects: students_to_remind.append(student) + if students_to_remind: mailer.students_reminder(students_to_remind, urgent=True) + logger.info(f"Sent reminders to {len(students_to_remind)} students") def start_assignment(program_id: int): diff --git a/src/utils/nav.py b/src/utils/nav.py index fdf362b..70be963 100644 --- a/src/utils/nav.py +++ b/src/utils/nav.py @@ -2,6 +2,7 @@ import streamlit as st from models.role import Role +from utils.logger import logger login_page = st.Page("views/login.py", title="Login") manage_projects_page = st.Page("views/manage_projects.py", title="Manage Projects") @@ -58,4 +59,5 @@ def protect(page_name: str): allowed_roles = PAGE_ROLES.get(page_name, []) if not allowed(roles, allowed_roles): + logger.warn(f"Access denied to {page_name} for user: {user.ldap_uid}") st.switch_page(projects_page) diff --git a/src/views/login.py b/src/views/login.py index 5fcefde..0cfb745 100644 --- a/src/views/login.py +++ b/src/views/login.py @@ -2,6 +2,7 @@ import streamlit as st from services.auth import login from services.db import get_db +from utils.logger import logger db = get_db() @@ -12,9 +13,11 @@ uid = st.text_input("UID", key="uid") password = st.text_input("Password", key="password", type="password") - if st.form_submit_button("Login", key="login_btn"): + if st.form_submit_button("Login", key="login_btn"): if user := login(uid, password): st.session_state.user = user + logger.info(f"User logged in: {uid}") st.success("Successfully logged in") else: + logger.warn(f"Invalid credentials for: {uid}") st.error("Invalid credentials") diff --git a/src/views/manage_projects.py b/src/views/manage_projects.py index 9a4cd40..101785e 100644 --- a/src/views/manage_projects.py +++ b/src/views/manage_projects.py @@ -5,6 +5,7 @@ from services.db import get_db from services.mail import Mailer from utils.nav import protect +from utils.logger import logger protect("manage_projects") @@ -74,11 +75,13 @@ project = db.create_project(st.session_state.user.id, teacher_id, project_dict["title"], project_dict["description"], f"specs/{specs.name}", st.session_state.program_id) if project: + logger.info(f"Project created: {project_dict['title']}") st.success("Project created successfully !") mailer = Mailer() mailer.project_creation(project) else: + logger.warn(f"Project creation failed: {project_dict['title']}") st.error("Project already exists !") elif sb and mode == "manual" and specs is not None and description != "" and title != "": @@ -96,11 +99,13 @@ ) if project: + logger.info(f"Project created: {title}") st.success("Project created successfully !") mailer = Mailer() mailer.project_creation(project) else: + logger.warn(f"Project creation failed: {title}") st.error("Project already exists !") elif sb and mode == "manual" and ( specs is None or description == "" or title == "" ): diff --git a/src/views/project_detail.py b/src/views/project_detail.py index a770676..b194851 100644 --- a/src/views/project_detail.py +++ b/src/views/project_detail.py @@ -6,6 +6,7 @@ from services.db import get_db from services.mail import Mailer from utils.nav import allowed, projects_page, protect +from utils.logger import logger protect("project_detail") @@ -113,11 +114,13 @@ if st.form_submit_button("Save"): result = db.update_project(project.id, title, description, teacher_id) if result: + logger.info(f"Project updated: {project.id}") db.update_project_keywords(project.id, st.session_state[edit_key]) del st.session_state[edit_key] st.session_state.edit_project = False st.rerun() else: + logger.warn(f"Project update failed: {project.id} - title may already exist") st.error("Failed to update project. Title may already exist.") st.space("stretch") @@ -133,6 +136,7 @@ with st.container(horizontal=True): if st.button("Confirm Delete", type="primary"): + logger.info(f"Project deleted: {project.id}") db.remove(project) st.session_state.confirm_delete = False st.switch_page(projects_page) From bddc2bc21310b53893563af4c1132b2f8fe9e425 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Mon, 11 May 2026 15:24:33 +0200 Subject: [PATCH 5/5] Log mock in tests --- src/tests/unit/test_assignment.py | 7 +++++++ src/tests/unit/test_ldap.py | 7 +++++++ src/utils/assignment.py | 4 +++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/tests/unit/test_assignment.py b/src/tests/unit/test_assignment.py index 18654eb..9dbce12 100644 --- a/src/tests/unit/test_assignment.py +++ b/src/tests/unit/test_assignment.py @@ -1,4 +1,5 @@ from unittest.mock import patch, MagicMock +import pytest import numpy as np from utils.assignment import ( assignment_algorithm, @@ -7,6 +8,12 @@ ) +@pytest.fixture(autouse=True) +def mock_logger(): + with patch("utils.assignment.logger") as mock: + yield mock + + class TestAssignmentAlgorithm: """Tests for the assignment_algorithm function.""" diff --git a/src/tests/unit/test_ldap.py b/src/tests/unit/test_ldap.py index b11b15c..e15094c 100644 --- a/src/tests/unit/test_ldap.py +++ b/src/tests/unit/test_ldap.py @@ -1,4 +1,5 @@ from unittest.mock import patch, MagicMock +import pytest from services.ldap import ( _get_server, _search_user, @@ -7,6 +8,12 @@ ) +@pytest.fixture(autouse=True) +def mock_logger(): + with patch("services.ldap.logger") as mock: + yield mock + + class TestSearchUser: """Tests for the _search_user function.""" diff --git a/src/utils/assignment.py b/src/utils/assignment.py index f896a15..1b00c81 100644 --- a/src/utils/assignment.py +++ b/src/utils/assignment.py @@ -76,7 +76,9 @@ def assign_projects(program_id: int, project_ratings: Sequence[ProjectRating], d db.assign_project(project_id, student_id) - logger.info(f"Assignment complete for program {project_ratings[0].project.program.name}, emails sent") + if len(project_ratings) > 0: + logger.info(f"Assignment complete for program {project_ratings[0].project.program.name}, emails sent") + mailer.project_assignment(program_id) def remind_students(students: Sequence[User], n_projects: int, mailer: Mailer):