From 090a27cd6e2d236cfa3d5f742b70adcd83428f97 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Thu, 7 May 2026 16:04:33 +0200 Subject: [PATCH 01/17] Compose.yml rework and environment variables --- .env.example | 21 +++++++++ .gitignore | 2 + compose.yml | 117 +++++++++++++++++---------------------------------- 3 files changed, 61 insertions(+), 79 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ea7ee0 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ + +# FreeIPA +IPA_SERVER_HOSTNAME= +IPA_PASSWORD= + +# PostgreSQL +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= + +# tMatch +LDAP_HOST= +LDAP_PORT= +LDAP_USER= +LDAP_PASSWORD= +LDAP_BASE_DN= + +SMTP_SERVER= +SMTP_PORT= +SMTP_USERNAME= +SMTP_PASSWORD= diff --git a/.gitignore b/.gitignore index d1ca65b..4f802ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +.env +srv/ certs/ src/**/__pycache__ src/specs/* diff --git a/compose.yml b/compose.yml index dfae74d..9362479 100644 --- a/compose.yml +++ b/compose.yml @@ -1,124 +1,83 @@ - services: freeipa: image: freeipa/freeipa-server:almalinux-10 container_name: freeipa-server - hostname: ipa.example.test + hostname: auth-tmatch.isc-vs.dev tty: true stdin_open: true - + privileged: false - cap_add: - - SYS_ADMIN - security_opt: - - seccomp=unconfined - - apparmor=unconfined - - cgroup: host - + environment: - - IPA_SERVER_HOSTNAME=ipa.example.test - - PASSWORD=thaitiePh5Haevie0cah - - SYSTEMD_SECCOMP=0 - + - IPA_SERVER_HOSTNAME=${IPA_SERVER_HOSTNAME} + - PASSWORD=${IPA_PASSWORD} + volumes: - - ./ipa_data:/data:Z - - /sys/fs/cgroup:/sys/fs/cgroup:rw - + - ./srv/freeipa-data:/data:Z + tmpfs: - /run - /tmp - - /var/lib/journal - - /run/lock - + ports: - - "3180:80" - - "31443:443" - - "31389:389" - - "31636:636" - - "3188:88" - - "3188:88/udp" - - "31464:464" - - "31464:464/udp" - + - "12343:443" + - "12389:389" + - "12336:636" + command: - ipa-server-install - -U - -r - - EXAMPLE.TEST + - TMATCH.ISC-VS.DEV - --domain - - example.test + - tmatch.isc-vs.dev - --no-ntp - --no-host-dns - --skip-mem-check - - restart: unless-stopped - - keycloak: - image: quay.io/keycloak/keycloak:26.5.3 - container_name: keycloak-server - environment: - - KC_BOOTSTRAP_ADMIN_USERNAME=admin - - KC_BOOTSTRAP_ADMIN_PASSWORD=admin - - KC_DB=postgres - - KC_DB_URL_HOST=keycloak-db - - KC_DB_URL_DATABASE=keycloak - - KC_DB_USERNAME=keycloak - - KC_DB_PASSWORD=0291040404 - - KC_HOSTNAME=ipa.example.test - - KC_HTTPS_CERTIFICATE_FILE=/opt/keycloak/conf/server.crt - - KC_HTTPS_CERTIFICATE_KEY_FILE=/opt/keycloak/conf/server.key - volumes: - - /srv/tmatch/keycloak/providers:/opt/keycloak/providers - - /srv/tmatch/keycloak/themes:/opt/keycloak/themes - - ./certs/server.crt:/opt/keycloak/conf/server.crt - - ./certs/server.key:/opt/keycloak/conf/server.key - ports: - - "3181:8080" - - "31443:8443" - command: - - start - restart: unless-stopped - depends_on: - - keycloak-db - - freeipa - keycloak-db: - image: postgres:16-alpine - container_name: keycloak_db - environment: - - POSTGRES_DB=keycloak - - POSTGRES_USER=keycloak - - POSTGRES_PASSWORD=nieM1ui7ak9sheitohsh - volumes: - - ./keycloak_data:/var/lib/postgresql/data restart: unless-stopped tmatch_db: image: postgres:16-alpine container_name: tmatch_db environment: - - POSTGRES_DB=tmatch_db - - POSTGRES_USER=tmatch - - POSTGRES_PASSWORD=Oochoghahp6ie7aiza9p + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} ports: - "3132:5432" restart: unless-stopped volumes: - - tmatch_db_data:/var/lib/postgresql/data + - ./srv/tmatch_db_data:/var/lib/postgresql/data tmatch_app: build: . container_name: tmatch_app restart: unless-stopped + environment: + - LDAP_HOST=${LDAP_HOST} + - LDAP_PORT=${LDAP_PORT} + - LDAP_USER=${LDAP_USER} + - LDAP_PASSWORD=${LDAP_PASSWORD} + - LDAP_BASE_DN=${LDAP_BASE_DN} + + - SMTP_SERVER=${SMTP_SERVER} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USERNAME=${SMTP_USERNAME} + - SMTP_PASSWORD=${SMTP_PASSWORD} + + - DB_NAMe=${POSTGRES_DB} + - DB_USER=${POSTGRES_USER} + - DB_PASSWORD=${POSTGRES_PASSWORD} ports: - "8084:8501" depends_on: - tmatch_db - freeipa volumes: - - tmatch_specs_data:/app/specs + - ./srv/tmatch_specs_data:/app/specs volumes: - tmatch_specs_data: - tmatch_db_data: + tmatch_specs_data: + tmatch_db_data: + tmatch_keycloak_data: From ed5b5e545b064ccb1901847f7db0fb3aaa0591d7 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 08:18:02 +0200 Subject: [PATCH 02/17] environment typo fix --- compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yml b/compose.yml index 9362479..0c9a10a 100644 --- a/compose.yml +++ b/compose.yml @@ -66,7 +66,7 @@ services: - SMTP_USERNAME=${SMTP_USERNAME} - SMTP_PASSWORD=${SMTP_PASSWORD} - - DB_NAMe=${POSTGRES_DB} + - DB_NAME=${POSTGRES_DB} - DB_USER=${POSTGRES_USER} - DB_PASSWORD=${POSTGRES_PASSWORD} ports: From b1c67d58f314509b97cbf4034681c50381843610 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 08:37:05 +0200 Subject: [PATCH 03/17] config.py --- src/config.py | 25 +++++++++++++++++++++++++ src/services/ldap.py | 8 ++++---- src/services/mail.py | 12 ++++++------ 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/config.py diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..86f605b --- /dev/null +++ b/src/config.py @@ -0,0 +1,25 @@ +import os + +# PostgreSQL +DB_HOST = "tmatch_db" +DB_PORT = 5432 +DB_NAME = os.environ.get("DB_NAME", "") +DB_USER = os.environ.get("DB_USER", "") +DB_PASSWORD = os.environ.get("DB_PASSWORD", "") + +DATABASE_URL = ( + f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +) + +# LDAP +LDAP_HOST = os.environ.get("LDAP_HOST", "") +LDAP_PORT = os.environ.get("LDAP_PORT", "") +LDAP_USER = os.environ.get("LDAP_USER", "") +LDAP_PASSWORD = os.environ.get("LDAP_PASSWORD", "") +LDAP_BASE_DN = os.environ.get("LDAP_BASE_DN", "") + +# Email +SMTP_SERVER = os.environ.get("SMTP_SERVER", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", 587)) +SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") diff --git a/src/services/ldap.py b/src/services/ldap.py index 1c65de6..9cd5584 100644 --- a/src/services/ldap.py +++ b/src/services/ldap.py @@ -1,9 +1,9 @@ -import streamlit as st +from config import LDAP_BASE_DN, LDAP_HOST, LDAP_PASSWORD, LDAP_PORT, LDAP_USER from ldap3 import Entry, Server, Connection, ALL def _get_server(): - return Server(st.secrets.ldap.ldaphost, port=int(st.secrets.ldap.ldapport), use_ssl=True, get_info=ALL) + return Server(LDAP_HOST, port=int(LDAP_PORT), use_ssl=True, get_info=ALL) def _search_user(uid: str, attributes: list[str]) -> Entry | None: @@ -21,8 +21,8 @@ def _search_user(uid: str, attributes: list[str]) -> Entry | None: return None try: server = _get_server() - conn = Connection(server, user=st.secrets.ldap.ldapaccount, password=st.secrets.ldap.ldappassword, auto_bind=True) - conn.search(st.secrets.ldap.ldapbasedn, f"(uid={uid})", "SUBTREE", attributes=attributes) + conn = Connection(server, user=LDAP_USER, password=LDAP_PASSWORD, auto_bind=True) + conn.search(LDAP_BASE_DN, f"(uid={uid})", "SUBTREE", attributes=attributes) entry = conn.entries[0] if conn.entries else None conn.unbind() return entry diff --git a/src/services/mail.py b/src/services/mail.py index 5ef93c2..503fe49 100644 --- a/src/services/mail.py +++ b/src/services/mail.py @@ -5,9 +5,9 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import ssl -import streamlit as st from jinja2 import Environment, FileSystemLoader +from config import SMTP_PASSWORD, SMTP_SERVER, SMTP_PORT, SMTP_USER from models.project import Project from models.user import User from services.db import Db, get_db @@ -34,11 +34,11 @@ class Mailer: _sender: str def __init__(self) -> None: - self._server = st.secrets.mailer.smtpserver - self._port = st.secrets.mailer.smtpserverport - self._username = st.secrets.mailer.smtpusername - self._password = st.secrets.mailer.smtppassword - self._sender = st.secrets.mailer.sender + self._server = SMTP_SERVER + self._port = SMTP_PORT + self._username = SMTP_USER + self._password = SMTP_PASSWORD + self._sender = SMTP_USER def project_supervision(self, project: Project): """Notify a teacher that a project has been assigned to them. From b17b961bb1e0f99934d676251c1e9da90f7a5566 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 08:58:36 +0200 Subject: [PATCH 04/17] dockerfile copy error and db connection --- dockerfile | 2 +- src/services/db.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dockerfile b/dockerfile index 711b62b..ebeecf9 100644 --- a/dockerfile +++ b/dockerfile @@ -12,7 +12,7 @@ WORKDIR /app COPY src/pyproject.toml src/uv.lock . RUN uv sync --frozen --no-install-project -COPY src/* . +COPY src/ . RUN uv sync --frozen diff --git a/src/services/db.py b/src/services/db.py index 0001686..dcb2524 100644 --- a/src/services/db.py +++ b/src/services/db.py @@ -1,10 +1,12 @@ from collections.abc import Sequence from datetime import datetime, timedelta, timezone -from sqlalchemy import select +from sqlalchemy import create_engine, select from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker import streamlit as st from streamlit.connections import SQLConnection +from config import DATABASE_URL from models.auth_token import AuthToken from models.base import Base from models.program import Program @@ -25,7 +27,12 @@ class Db: _conn: SQLConnection def __init__(self) -> None: - self._conn = st.connection("tmatch_db", type="sql") + self._conn = self._get_session_factory() + + @st.cache_resource + def _get_session_factory(): + engine = create_engine(DATABASE_URL) + return sessionmaker(bind=engine) def create_project(self, created_by: int, teacher_id: int, title: str, description: str, specifications: str, program_id: int) -> Project | None: """Create a new project in the database. From d0b6392ff195ea99ac3f1c75e9289174f7b8ee8d Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:12:21 +0200 Subject: [PATCH 05/17] Session factory --- src/services/db.py | 60 ++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/services/db.py b/src/services/db.py index dcb2524..4c89a24 100644 --- a/src/services/db.py +++ b/src/services/db.py @@ -4,7 +4,6 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker import streamlit as st -from streamlit.connections import SQLConnection from config import DATABASE_URL from models.auth_token import AuthToken @@ -20,19 +19,16 @@ from models.keyword import Keyword from models.projects_keyword import ProjectsKeyword +@st.cache_resource +def get_session_factory(): + engine = create_engine(DATABASE_URL) + return sessionmaker(bind=engine) class Db: """Database service class for CRUD operations.""" - _conn: SQLConnection - def __init__(self) -> None: - self._conn = self._get_session_factory() - - @st.cache_resource - def _get_session_factory(): - engine = create_engine(DATABASE_URL) - return sessionmaker(bind=engine) + self._session_factory = get_session_factory() def create_project(self, created_by: int, teacher_id: int, title: str, description: str, specifications: str, program_id: int) -> Project | None: """Create a new project in the database. @@ -49,7 +45,7 @@ def create_project(self, created_by: int, teacher_id: int, title: str, descripti The created Project object, or None if a database integrity error occurs. """ - with self._conn.session as s: + with self._session_factory() as s: project = Project( created_by=created_by, teacher_id=teacher_id, @@ -80,7 +76,7 @@ def create_session(self, user_id: int) -> Session: The created Session object. """ - with self._conn.session as s: + with self._session_factory() as s: session = Session( user_id=user_id, expires_at=datetime.now(timezone.utc) + timedelta(days=7), @@ -102,7 +98,7 @@ def create_auth_token(self, user_id: int) -> AuthToken: The created AuthToken object. """ - with self._conn.session as s: + with self._session_factory() as s: auth_token = AuthToken( user_id=user_id, expires_at=datetime.now(timezone.utc) + timedelta(seconds=30), @@ -129,7 +125,7 @@ def apply_rating(self, project_id: int, student_id: int, rating: int) -> Project The created or updated ProjectRating object. """ - with self._conn.session as s: + with self._session_factory() as s: existing = s.execute( select(ProjectRating) .where(ProjectRating.student_id == student_id) @@ -163,7 +159,7 @@ def assign_project(self, project_id: int, student_id: int) -> None: student_id: ID of the student to assign the project to. """ - with self._conn.session as s: + with self._session_factory() as s: project = s.execute( select(Project).where(Project.id == project_id) ).scalar_one() @@ -185,7 +181,7 @@ def get_rating(self, project_id: int, student_id: int) -> ProjectRating|None: The ProjectRating object, or None if not found. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute(select(ProjectRating) .where(ProjectRating.student_id == student_id) .where(ProjectRating.project_id == project_id) @@ -201,7 +197,7 @@ def get_ratings(self, program_id: int) -> Sequence[ProjectRating]: Sequence of all ProjectRating objects for the program. """ - with self._conn.session as s: + with self._session_factory() as s: return ( s.execute( select(ProjectRating) @@ -222,7 +218,7 @@ def get_projects(self, program_id: int) -> Sequence[Project]: Sequence of all Project objects for the program. """ - with self._conn.session as s: + with self._session_factory() as s: return ( s.execute( select(Project) @@ -243,7 +239,7 @@ def get_teachers(self, program_id: int) -> Sequence[User]: Sequence of all User objects with teacher role in the program. """ - with self._conn.session as s: + with self._session_factory() as s: return ( s.execute( select(User) @@ -268,7 +264,7 @@ def get_students(self, program_id: int) -> Sequence[User]: Sequence of all User objects with student role in the program. """ - with self._conn.session as s: + with self._session_factory() as s: return ( s.execute( select(User) @@ -290,7 +286,7 @@ def get_keywords(self) -> Sequence[Keyword]: Sequence of all Keyword objects. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute(select(Keyword)).scalars().all() def update_project_keywords(self, project_id: int, keyword_ids: list[int]) -> None: @@ -301,7 +297,7 @@ def update_project_keywords(self, project_id: int, keyword_ids: list[int]) -> No keyword_ids: List of keyword IDs to associate with the project. """ - with self._conn.session as s: + with self._session_factory() as s: s.execute( delete(ProjectsKeyword) .where(ProjectsKeyword.project_id == project_id) @@ -317,7 +313,7 @@ def get_users(self) -> Sequence[User]: Sequence of all User objects. """ - with self._conn.session as s: + with self._session_factory() as s: return ( s.execute( select(User) @@ -336,7 +332,7 @@ def get_programs(self) -> Sequence[Program]: Sequence of all Program objects. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute(select(Program)).scalars().all() def get_roles(self) -> Sequence[Role]: @@ -346,7 +342,7 @@ def get_roles(self) -> Sequence[Role]: Sequence of all Role objects. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute(select(Role)).scalars().all() def update_user_role(self, user_id: int, program_id: int, role_name: str) -> None: @@ -358,7 +354,7 @@ def update_user_role(self, user_id: int, program_id: int, role_name: str) -> Non role_name: Name of the role to assign. """ - with self._conn.session as s: + with self._session_factory() as s: role = s.execute( select(Role).where(Role.name == role_name) ).scalar_one_or_none() @@ -386,7 +382,7 @@ def get_user(self, uid: str) -> User: The User object (existing or newly created). """ - with self._conn.session as s: + with self._session_factory() as s: user = s.execute( select(User).where(User.ldap_uid == uid) ).scalar_one_or_none() @@ -411,7 +407,7 @@ def get_project(self, project_id: int) -> Project|None: The Project object, or None if not found. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute( select(Project).where(Project.id == project_id) ).scalar_one_or_none() @@ -426,7 +422,7 @@ def get_session(self, sid: str) -> Session | None: The Session object, or None if not found. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute( select(Session).where(Session.id == sid) ).scalar_one_or_none() @@ -441,7 +437,7 @@ def get_auth_token(self, token_id: str) -> AuthToken | None: The AuthToken object, or None if not found. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute( select(AuthToken).where(AuthToken.id == token_id) ).scalar_one_or_none() @@ -456,7 +452,7 @@ def get_program(self, program_id: int) -> Program | None: The Program object, or None if not found. """ - with self._conn.session as s: + with self._session_factory() as s: return s.execute( select(Program).where(Program.id == program_id) ).scalar_one_or_none() @@ -474,7 +470,7 @@ def update_project(self, project_id: int, title: str, description: str, teacher_ The updated Project object, or None if not found or on integrity error. """ - with self._conn.session as s: + with self._session_factory() as s: project = s.execute( select(Project).where(Project.id == project_id) ).scalar_one_or_none() @@ -501,7 +497,7 @@ def remove(self, model: Base) -> None: Cascade behavior depends on the model configuration. """ - with self._conn.session as s: + with self._session_factory() as s: s.delete(model) s.commit() From 96733d7ea53cc2ffb0cb8c8a77df229b01cb6b37 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:16:30 +0200 Subject: [PATCH 06/17] Use psycopg --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index 86f605b..59c2538 100644 --- a/src/config.py +++ b/src/config.py @@ -8,7 +8,7 @@ DB_PASSWORD = os.environ.get("DB_PASSWORD", "") DATABASE_URL = ( - f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + f"postgresql+psycopg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ) # LDAP From bf5d71d7668b62049fb10a4e93341acbab0d9caf Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:46:14 +0200 Subject: [PATCH 07/17] Database seeding fix --- dockerfile | 2 +- entrypoint.sh | 12 ++++++++++++ src/seed/engine.py | 3 ++- src/seed/keywords.py | 11 ++++++----- src/seed/programs.py | 22 ++++++++++++++++++++++ src/seed/roles.py | 20 ++++++++++---------- src/seed/seed.py | 18 +++++++++++++++--- 7 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 entrypoint.sh create mode 100644 src/seed/programs.py diff --git a/dockerfile b/dockerfile index ebeecf9..509fd39 100644 --- a/dockerfile +++ b/dockerfile @@ -16,5 +16,5 @@ COPY src/ . RUN uv sync --frozen -CMD ["uv", "run", "streamlit", "run", "main.py"] +CMD ["./entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7a016d6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,12 @@ + +#!/bin/sh +set -e + +echo "Running migrations..." +uv run alembic upgrade head + +echo "Seeding database..." +uv run python seed/seed.py + +echo "Starting app..." +exec uv run streamlit run main.py diff --git a/src/seed/engine.py b/src/seed/engine.py index c6e53c1..e955974 100644 --- a/src/seed/engine.py +++ b/src/seed/engine.py @@ -1,4 +1,5 @@ from sqlalchemy import create_engine +from config import DATABASE_URL -engine = create_engine("postgresql+psycopg://postgres:postgres@localhost/tmatch_db") +engine = create_engine(DATABASE_URL) diff --git a/src/seed/keywords.py b/src/seed/keywords.py index 1e5436b..7d91a4f 100644 --- a/src/seed/keywords.py +++ b/src/seed/keywords.py @@ -4,7 +4,6 @@ from seed.engine import engine Session = sessionmaker(bind=engine) -session = Session() def create_keywords(): keywords = [ @@ -86,8 +85,10 @@ def create_keywords(): "forecasting", ] - for i in range(len(keywords)): - new_keyword = Keyword(name=keywords[i]) + with Session() as s: + for i in range(len(keywords)): + new_keyword = Keyword(name=keywords[i]) - session.add(new_keyword) - session.commit() + s.add(new_keyword) + + s.commit() diff --git a/src/seed/programs.py b/src/seed/programs.py new file mode 100644 index 0000000..0f46f72 --- /dev/null +++ b/src/seed/programs.py @@ -0,0 +1,22 @@ + +from sqlalchemy.orm import sessionmaker + +from models.program import Program +from seed.engine import engine + +Session = sessionmaker(bind=engine) + +def create_programs(): + programs = [ + "ISC", + "SYND", + "LSE", + "ETE" + ] + + with Session() as s: + for i in range(len(programs)): + new_program = Program(name=programs[i]) + s.add(new_program) + + s.commit() diff --git a/src/seed/roles.py b/src/seed/roles.py index a71ea65..3fcffd1 100644 --- a/src/seed/roles.py +++ b/src/seed/roles.py @@ -4,17 +4,17 @@ from seed.engine import engine Session = sessionmaker(bind=engine) -session = Session() def create_roles(): - student = Role(name="student") - teacher = Role(name="teacher") - secretary = Role(name="secretary") - program_director = Role(name="program director") + with Session() as s: + student = Role(name="student") + teacher = Role(name="teacher") + secretary = Role(name="secretary") + program_director = Role(name="program director") - session.add(student) - session.add(teacher) - session.add(secretary) - session.add(program_director) + s.add(student) + s.add(teacher) + s.add(secretary) + s.add(program_director) - session.commit() + s.commit() diff --git a/src/seed/seed.py b/src/seed/seed.py index 2360eb0..1d562d7 100644 --- a/src/seed/seed.py +++ b/src/seed/seed.py @@ -1,13 +1,25 @@ +from sqlalchemy.orm import sessionmaker +from models.role import Role from seed.keywords import create_keywords +from seed.programs import create_programs from seed.roles import create_roles -print("This is the seed script. It should only be run when the database is freshly created.") -print("It will insert basic keywords and required roles inside the database.") -input("Please press enter to continue.") +from seed.engine import engine + +Session = sessionmaker(bind=engine) +session = Session() + +with session as s: + if s.query(Role).count() > 0: + print("Database already seeded. Skipping.") + exit(0) create_roles() print("Created roles") create_keywords() print("Created keywords") + +create_programs() +print("Created programs") From c70e2d5452ad4705ea0bf853f7f702b6388ccbe3 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:47:48 +0200 Subject: [PATCH 08/17] entrypoint copy --- dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dockerfile b/dockerfile index 509fd39..00eb0d6 100644 --- a/dockerfile +++ b/dockerfile @@ -16,5 +16,8 @@ COPY src/ . RUN uv sync --frozen +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh + CMD ["./entrypoint.sh"] From da90a039d95f5ff3afec66741da208eb316ca662 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:50:35 +0200 Subject: [PATCH 09/17] entrypoint sh --- dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index 00eb0d6..3367660 100644 --- a/dockerfile +++ b/dockerfile @@ -19,5 +19,5 @@ RUN uv sync --frozen COPY entrypoint.sh ./ RUN chmod +x entrypoint.sh -CMD ["./entrypoint.sh"] +CMD ["sh", "./entrypoint.sh"] From c2e18e8e6088a1a1dc92395a8b2a8bff4a36d0c4 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 09:55:20 +0200 Subject: [PATCH 10/17] URL runtime change --- src/alembic/env.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/alembic/env.py b/src/alembic/env.py index 55c0ce3..5e3c004 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -5,12 +5,15 @@ from alembic import context +from config import DATABASE_URL from models import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +config.set_main_option("sqlalchemy.url", DATABASE_URL) + # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: From 59ddde36a55b25890a61294e90b956d55c106d4e Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 10:21:29 +0200 Subject: [PATCH 11/17] Seed location change --- entrypoint.sh | 2 +- src/{seed => }/seed.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{seed => }/seed.py (100%) diff --git a/entrypoint.sh b/entrypoint.sh index 7a016d6..b622abc 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,7 +6,7 @@ echo "Running migrations..." uv run alembic upgrade head echo "Seeding database..." -uv run python seed/seed.py +uv run python seed.py echo "Starting app..." exec uv run streamlit run main.py diff --git a/src/seed/seed.py b/src/seed.py similarity index 100% rename from src/seed/seed.py rename to src/seed.py From a5d2720547a2efa029c0783b4c554d44678253b0 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 10:27:22 +0200 Subject: [PATCH 12/17] seed to seeds --- src/seed.py | 8 ++++---- src/{seed => seeds}/engine.py | 0 src/{seed => seeds}/keywords.py | 2 +- src/{seed => seeds}/programs.py | 2 +- src/{seed => seeds}/roles.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/{seed => seeds}/engine.py (100%) rename src/{seed => seeds}/keywords.py (98%) rename src/{seed => seeds}/programs.py (92%) rename src/{seed => seeds}/roles.py (93%) diff --git a/src/seed.py b/src/seed.py index 1d562d7..cbae50b 100644 --- a/src/seed.py +++ b/src/seed.py @@ -1,11 +1,11 @@ from sqlalchemy.orm import sessionmaker from models.role import Role -from seed.keywords import create_keywords -from seed.programs import create_programs -from seed.roles import create_roles +from seeds.keywords import create_keywords +from seeds.programs import create_programs +from seeds.roles import create_roles -from seed.engine import engine +from seeds.engine import engine Session = sessionmaker(bind=engine) session = Session() diff --git a/src/seed/engine.py b/src/seeds/engine.py similarity index 100% rename from src/seed/engine.py rename to src/seeds/engine.py diff --git a/src/seed/keywords.py b/src/seeds/keywords.py similarity index 98% rename from src/seed/keywords.py rename to src/seeds/keywords.py index 7d91a4f..380ccbd 100644 --- a/src/seed/keywords.py +++ b/src/seeds/keywords.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import sessionmaker from models.keyword import Keyword -from seed.engine import engine +from seeds.engine import engine Session = sessionmaker(bind=engine) diff --git a/src/seed/programs.py b/src/seeds/programs.py similarity index 92% rename from src/seed/programs.py rename to src/seeds/programs.py index 0f46f72..797ad06 100644 --- a/src/seed/programs.py +++ b/src/seeds/programs.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import sessionmaker from models.program import Program -from seed.engine import engine +from seeds.engine import engine Session = sessionmaker(bind=engine) diff --git a/src/seed/roles.py b/src/seeds/roles.py similarity index 93% rename from src/seed/roles.py rename to src/seeds/roles.py index 3fcffd1..d183990 100644 --- a/src/seed/roles.py +++ b/src/seeds/roles.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import sessionmaker from models.role import Role -from seed.engine import engine +from seeds.engine import engine Session = sessionmaker(bind=engine) From 3b543edaba7d68c5198608a1a0568572e716ff1c Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 10:38:27 +0200 Subject: [PATCH 13/17] LDAP Logs --- src/services/ldap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/ldap.py b/src/services/ldap.py index 9cd5584..001381b 100644 --- a/src/services/ldap.py +++ b/src/services/ldap.py @@ -22,6 +22,7 @@ def _search_user(uid: str, attributes: list[str]) -> Entry | None: try: server = _get_server() conn = Connection(server, user=LDAP_USER, password=LDAP_PASSWORD, auto_bind=True) + print("LDAP Connection successful") conn.search(LDAP_BASE_DN, f"(uid={uid})", "SUBTREE", attributes=attributes) entry = conn.entries[0] if conn.entries else None conn.unbind() @@ -55,6 +56,7 @@ def authenticate(uid: str, password: str) -> dict[str, str|None] | None: server = _get_server() auth_conn = Connection(server, user=user_dn, password=password) if not auth_conn.bind(): + print("invalid credentials") auth_conn.unbind() return None From ab35c10d2a7db6bf510e12cbe6bd9a3f9bb77233 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 10:55:48 +0200 Subject: [PATCH 14/17] LDAP Logs 2 --- src/services/ldap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/ldap.py b/src/services/ldap.py index 001381b..facbf0c 100644 --- a/src/services/ldap.py +++ b/src/services/ldap.py @@ -22,13 +22,13 @@ def _search_user(uid: str, attributes: list[str]) -> Entry | None: try: server = _get_server() conn = Connection(server, user=LDAP_USER, password=LDAP_PASSWORD, auto_bind=True) - print("LDAP Connection successful") conn.search(LDAP_BASE_DN, f"(uid={uid})", "SUBTREE", attributes=attributes) entry = conn.entries[0] if conn.entries else None conn.unbind() return entry except Exception as e: print(e) + print("search bind error") return None @@ -56,7 +56,6 @@ def authenticate(uid: str, password: str) -> dict[str, str|None] | None: server = _get_server() auth_conn = Connection(server, user=user_dn, password=password) if not auth_conn.bind(): - print("invalid credentials") auth_conn.unbind() return None @@ -71,6 +70,7 @@ def authenticate(uid: str, password: str) -> dict[str, str|None] | None: except Exception as e: print(e) + print("auth bind error") return None From d5862655f604eb02e7375830b8b6a7e6c351494b Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 11:26:10 +0200 Subject: [PATCH 15/17] Mailer log --- src/services/mail.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/mail.py b/src/services/mail.py index 503fe49..34dd862 100644 --- a/src/services/mail.py +++ b/src/services/mail.py @@ -40,6 +40,8 @@ def __init__(self) -> None: self._password = SMTP_PASSWORD self._sender = SMTP_USER + print(self._port, self._server, self._username, self._password) + def project_supervision(self, project: Project): """Notify a teacher that a project has been assigned to them. From aa573107939637ab4c1373b338a1915f60bdb758 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 11:29:24 +0200 Subject: [PATCH 16/17] SMTP_USER to SMTP_USERNAME --- src/config.py | 2 +- src/services/mail.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/config.py b/src/config.py index 59c2538..53ff270 100644 --- a/src/config.py +++ b/src/config.py @@ -21,5 +21,5 @@ # Email SMTP_SERVER = os.environ.get("SMTP_SERVER", "") SMTP_PORT = int(os.environ.get("SMTP_PORT", 587)) -SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "") SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") diff --git a/src/services/mail.py b/src/services/mail.py index 34dd862..07423f5 100644 --- a/src/services/mail.py +++ b/src/services/mail.py @@ -7,7 +7,7 @@ import ssl from jinja2 import Environment, FileSystemLoader -from config import SMTP_PASSWORD, SMTP_SERVER, SMTP_PORT, SMTP_USER +from config import SMTP_PASSWORD, SMTP_SERVER, SMTP_PORT, SMTP_USERNAME from models.project import Project from models.user import User from services.db import Db, get_db @@ -36,11 +36,9 @@ class Mailer: def __init__(self) -> None: self._server = SMTP_SERVER self._port = SMTP_PORT - self._username = SMTP_USER + self._username = SMTP_USERNAME self._password = SMTP_PASSWORD - self._sender = SMTP_USER - - print(self._port, self._server, self._username, self._password) + self._sender = SMTP_USERNAME def project_supervision(self, project: Project): """Notify a teacher that a project has been assigned to them. From 876468275b430e6b220714d63a261a432ccbeb14 Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Fri, 8 May 2026 11:41:39 +0200 Subject: [PATCH 17/17] page permissions rework --- src/app.py | 8 ++++---- src/utils/nav.py | 4 +--- src/views/landing.py | 10 ---------- src/views/projects.py | 11 +++++++---- 4 files changed, 12 insertions(+), 21 deletions(-) delete mode 100644 src/views/landing.py diff --git a/src/app.py b/src/app.py index 912cd3e..b90493c 100644 --- a/src/app.py +++ b/src/app.py @@ -2,7 +2,7 @@ import streamlit as st from services.auth import validate_session from services.db import get_db -from utils.nav import PAGE_ROLES, PAGE_CONFIG, allowed, landing_page, login_page +from utils.nav import PAGE_ROLES, PAGE_CONFIG, allowed, projects_page, login_page st.session_state.session = validate_session() @@ -50,15 +50,15 @@ def set_program(): st.session_state.program_id = program_id if len(roles) == 0: - page_list = [landing_page] + page_list = [projects_page] else: - page_list = [landing_page] + [ + page_list = [ PAGE_CONFIG[page_name] for page_name, allowed_roles in PAGE_ROLES.items() if allowed(roles, allowed_roles) ] if not page_list: - page_list = [landing_page] + page_list = [projects_page] else: page_list = [login_page] diff --git a/src/utils/nav.py b/src/utils/nav.py index 856242d..c94411c 100644 --- a/src/utils/nav.py +++ b/src/utils/nav.py @@ -4,14 +4,12 @@ from models.role import Role login_page = st.Page("views/login.py", title="Login", default=True) -landing_page = st.Page("views/landing.py", title="Home") manage_projects_page = st.Page("views/manage_projects.py", title="Manage Projects") project_detail_page = st.Page("views/project_detail.py", title="View Project Details", visibility="hidden") projects_page = st.Page("views/projects.py", title="View Projects List") assigned_project_page = st.Page("views/assigned_project.py", title="Assigned Project") PAGE_CONFIG = { - "landing": landing_page, "manage_projects": manage_projects_page, "project_detail": project_detail_page, "projects": projects_page, @@ -61,4 +59,4 @@ def protect(page_name: str): allowed_roles = PAGE_ROLES.get(page_name, []) if not allowed(roles, allowed_roles): - st.switch_page(landing_page) + st.switch_page(projects_page) diff --git a/src/views/landing.py b/src/views/landing.py deleted file mode 100644 index 183db3b..0000000 --- a/src/views/landing.py +++ /dev/null @@ -1,10 +0,0 @@ - -import streamlit as st - -from utils.nav import protect - -protect("landing") - -st.write("hello") -st.write() - diff --git a/src/views/projects.py b/src/views/projects.py index f0efc9c..0d1f5ce 100644 --- a/src/views/projects.py +++ b/src/views/projects.py @@ -63,17 +63,20 @@ def render_for_others(): st.divider() - cols = st.columns([4, 0.8]) + cols = st.columns([4, 3, 4, 1.5]) cols[0].markdown("**Project**") - + cols[1].markdown("**Teacher**") st.divider() for project in projects: pid = project.id - cols = st.columns([4, 0.8]) + + cols = st.columns([4, 3, 4, 1.5]) cols[0].write(project.title) + cols[1].write(project.teacher.ldap_uid) - if cols[1].button("Open", key=f"open_{pid}"): + + if cols[2].button("Detail", key=f"detail_{pid}"): st.session_state.selected_project = project.id st.switch_page(project_detail_page)