Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
b55899d
1
Parent(s): cc06e81
🚀 Auto-deploy from GitHub Actions - 2026-02-28 23:08:07
Browse files- .env.example +40 -0
- .gitignore +32 -0
- .streamlit/config.toml +11 -0
- Dockerfile +25 -0
- LICENSE +21 -0
- README.md +8 -8
- app.py +136 -0
- database/db_connection.py +48 -0
- database/init.py +27 -0
- database/queries.py +121 -0
- pages/1_📝_Nouvelle_candidature.py +112 -0
- pages/2_📊_Mes_candidatures.py +194 -0
- requirements.txt +13 -0
- utils/auth.py +40 -0
.env.example
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================
|
| 2 |
+
# NeonDB Configuration
|
| 3 |
+
# ==============================================
|
| 4 |
+
# Get your connection string from: https://console.neon.tech
|
| 5 |
+
# Format: postgresql://user:password@host/database?sslmode=require
|
| 6 |
+
NEON_DB="postgresql://user:password@ep-xyz-123.us-east-2.aws.neon.tech/neondb?sslmode=require"
|
| 7 |
+
|
| 8 |
+
# ==============================================
|
| 9 |
+
# HuggingFace Configuration (Optional - for local testing)
|
| 10 |
+
# ==============================================
|
| 11 |
+
# Only needed if you want to test HF deployment locally
|
| 12 |
+
# Get your token from: https://huggingface.co/settings/tokens
|
| 13 |
+
HF_TOKEN="hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
| 14 |
+
HF_USERNAME="your-username"
|
| 15 |
+
SPACE_NAME="job-tracker"
|
| 16 |
+
|
| 17 |
+
# ==============================================
|
| 18 |
+
# Application Configuration (Optional)
|
| 19 |
+
# ==============================================
|
| 20 |
+
# Uncomment and modify if needed
|
| 21 |
+
|
| 22 |
+
# Application name
|
| 23 |
+
# APP_NAME="Job Tracker"
|
| 24 |
+
|
| 25 |
+
# Debug mode (development only)
|
| 26 |
+
# DEBUG=False
|
| 27 |
+
|
| 28 |
+
# Session timeout (minutes)
|
| 29 |
+
# SESSION_TIMEOUT=30
|
| 30 |
+
|
| 31 |
+
# Max file upload size (MB)
|
| 32 |
+
# MAX_UPLOAD_SIZE=10
|
| 33 |
+
|
| 34 |
+
# ==============================================
|
| 35 |
+
# Notes
|
| 36 |
+
# ==============================================
|
| 37 |
+
# 1. Copy this file to .env and fill in your values
|
| 38 |
+
# 2. NEVER commit .env to git (it's in .gitignore)
|
| 39 |
+
# 3. For production, set NEON_DB in HuggingFace Space secrets
|
| 40 |
+
# 4. For GitHub Actions, set HF_TOKEN, HF_USERNAME, SPACE_NAME in GitHub Secrets
|
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
.venv/
|
| 10 |
+
|
| 11 |
+
# Secrets
|
| 12 |
+
.env
|
| 13 |
+
*.pem
|
| 14 |
+
*.key
|
| 15 |
+
|
| 16 |
+
# IDEs
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
| 19 |
+
*.swp
|
| 20 |
+
*.swo
|
| 21 |
+
|
| 22 |
+
# OS
|
| 23 |
+
.DS_Store
|
| 24 |
+
Thumbs.db
|
| 25 |
+
|
| 26 |
+
# Streamlit
|
| 27 |
+
.streamlit/secrets.toml
|
| 28 |
+
|
| 29 |
+
# Tests
|
| 30 |
+
.coverage
|
| 31 |
+
htmlcov/
|
| 32 |
+
.pytest_cache/
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
primaryColor = "#4CAF50"
|
| 3 |
+
backgroundColor = "#FFFFFF"
|
| 4 |
+
secondaryBackgroundColor = "#F0F2F6"
|
| 5 |
+
textColor = "#262730"
|
| 6 |
+
font = "sans serif"
|
| 7 |
+
|
| 8 |
+
[server]
|
| 9 |
+
headless = true
|
| 10 |
+
port = 7860
|
| 11 |
+
enableCORS = false
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Dépendances système si besoin (ex: pour psycopg2)
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
gcc \
|
| 8 |
+
postgresql-client \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copie des requirements
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# Copie du code
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
# Port Streamlit par défaut
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
# Healthcheck (optionnel mais propre)
|
| 22 |
+
HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health || exit 1
|
| 23 |
+
|
| 24 |
+
# Commande de lancement
|
| 25 |
+
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Albert ROMANO
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
title: Job Tracker
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: 'job-tracker for aapplication '
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Job Application Tracker
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker # ← IMPORTANT si tu utilises Dockerfile
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Job Application Tracker
|
| 11 |
+
|
| 12 |
+
Track your job applications with ease.
|
app.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from utils.auth import login, logout, register, is_logged_in
|
| 3 |
+
from database.db_connection import init_database
|
| 4 |
+
|
| 5 |
+
# Configuration de la page
|
| 6 |
+
st.set_page_config(
|
| 7 |
+
page_title="Job Tracker",
|
| 8 |
+
page_icon="📊",
|
| 9 |
+
layout="wide",
|
| 10 |
+
initial_sidebar_state="expanded"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
# Initialisation de la base de données
|
| 14 |
+
try:
|
| 15 |
+
init_database()
|
| 16 |
+
except Exception as e:
|
| 17 |
+
st.error(f"Erreur d'initialisation de la base de données : {e}")
|
| 18 |
+
|
| 19 |
+
# Initialisation du session_state
|
| 20 |
+
if 'logged_in' not in st.session_state:
|
| 21 |
+
st.session_state['logged_in'] = False
|
| 22 |
+
if 'user_id' not in st.session_state:
|
| 23 |
+
st.session_state['user_id'] = None
|
| 24 |
+
if 'username' not in st.session_state:
|
| 25 |
+
st.session_state['username'] = None
|
| 26 |
+
|
| 27 |
+
# ============ PAGE DE LOGIN/REGISTER ============
|
| 28 |
+
|
| 29 |
+
if not is_logged_in():
|
| 30 |
+
st.title("🔐 Job Application Tracker")
|
| 31 |
+
st.markdown("### Bienvenue ! Connectez-vous ou créez un compte")
|
| 32 |
+
|
| 33 |
+
tab1, tab2 = st.tabs(["Se connecter", "Créer un compte"])
|
| 34 |
+
|
| 35 |
+
# Tab Login
|
| 36 |
+
with tab1:
|
| 37 |
+
st.subheader("Connexion")
|
| 38 |
+
login_username = st.text_input("Nom d'utilisateur", key="login_user")
|
| 39 |
+
login_password = st.text_input("Mot de passe", type="password", key="login_pass")
|
| 40 |
+
|
| 41 |
+
if st.button("Se connecter", type="primary"):
|
| 42 |
+
if login_username and login_password:
|
| 43 |
+
if login(login_username, login_password):
|
| 44 |
+
st.success(f"Bienvenue {login_username} !")
|
| 45 |
+
st.rerun()
|
| 46 |
+
else:
|
| 47 |
+
st.error("Identifiants incorrects")
|
| 48 |
+
else:
|
| 49 |
+
st.warning("Veuillez remplir tous les champs")
|
| 50 |
+
|
| 51 |
+
# Tab Register
|
| 52 |
+
with tab2:
|
| 53 |
+
st.subheader("Créer un compte")
|
| 54 |
+
register_username = st.text_input("Nom d'utilisateur", key="register_user")
|
| 55 |
+
register_password = st.text_input("Mot de passe", type="password", key="register_pass")
|
| 56 |
+
register_password_confirm = st.text_input("Confirmer le mot de passe", type="password", key="register_pass_confirm")
|
| 57 |
+
|
| 58 |
+
if st.button("Créer mon compte", type="primary"):
|
| 59 |
+
if register_username and register_password and register_password_confirm:
|
| 60 |
+
if register_password != register_password_confirm:
|
| 61 |
+
st.error("Les mots de passe ne correspondent pas")
|
| 62 |
+
elif len(register_password) < 6:
|
| 63 |
+
st.error("Le mot de passe doit contenir au moins 6 caractères")
|
| 64 |
+
else:
|
| 65 |
+
success, message = register(register_username, register_password)
|
| 66 |
+
if success:
|
| 67 |
+
st.success(message)
|
| 68 |
+
st.info("Vous pouvez maintenant vous connecter !")
|
| 69 |
+
else:
|
| 70 |
+
st.error(message)
|
| 71 |
+
else:
|
| 72 |
+
st.warning("Veuillez remplir tous les champs")
|
| 73 |
+
|
| 74 |
+
# ============ PAGE PRINCIPALE (si connecté) ============
|
| 75 |
+
|
| 76 |
+
else:
|
| 77 |
+
# Sidebar avec infos user et logout
|
| 78 |
+
with st.sidebar:
|
| 79 |
+
st.markdown(f"### 👤 {st.session_state['username']}")
|
| 80 |
+
st.markdown("---")
|
| 81 |
+
|
| 82 |
+
if st.button("🚪 Se déconnecter", type="secondary"):
|
| 83 |
+
logout()
|
| 84 |
+
st.rerun()
|
| 85 |
+
|
| 86 |
+
st.markdown("---")
|
| 87 |
+
st.markdown("### Navigation")
|
| 88 |
+
st.info("Utilisez les pages dans le menu ci-dessus pour :\n- 📝 Ajouter une candidature\n- 📊 Voir vos candidatures")
|
| 89 |
+
|
| 90 |
+
# Page d'accueil
|
| 91 |
+
st.title("📊 Job Application Tracker")
|
| 92 |
+
st.markdown("### Bienvenue sur votre tableau de bord")
|
| 93 |
+
|
| 94 |
+
st.success("✅ Vous êtes connecté(e)")
|
| 95 |
+
|
| 96 |
+
st.markdown("""
|
| 97 |
+
### Comment utiliser l'application ?
|
| 98 |
+
|
| 99 |
+
1. **📝 Nouvelle candidature** : Ajoutez vos candidatures avec toutes les informations importantes
|
| 100 |
+
2. **📊 Mes candidatures** : Consultez, modifiez et suivez l'évolution de vos candidatures
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
#### Fonctionnalités disponibles :
|
| 105 |
+
- ✅ Suivi de vos candidatures
|
| 106 |
+
- ✅ Gestion des statuts (En attente, Relancé, Refusé, Accepté)
|
| 107 |
+
- ✅ Tri et filtrage
|
| 108 |
+
- ✅ Modification et suppression
|
| 109 |
+
|
| 110 |
+
""")
|
| 111 |
+
|
| 112 |
+
# Statistiques rapides (optionnel)
|
| 113 |
+
from database.queries import get_user_applications
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
applications = get_user_applications(st.session_state['user_id'])
|
| 117 |
+
|
| 118 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 119 |
+
|
| 120 |
+
with col1:
|
| 121 |
+
st.metric("Total candidatures", len(applications))
|
| 122 |
+
|
| 123 |
+
with col2:
|
| 124 |
+
en_attente = len([app for app in applications if app['status'] == 'En attente'])
|
| 125 |
+
st.metric("En attente", en_attente)
|
| 126 |
+
|
| 127 |
+
with col3:
|
| 128 |
+
acceptees = len([app for app in applications if app['status'] == 'Accepté'])
|
| 129 |
+
st.metric("Acceptées", acceptees)
|
| 130 |
+
|
| 131 |
+
with col4:
|
| 132 |
+
refusees = len([app for app in applications if app['status'] == 'Refusé'])
|
| 133 |
+
st.metric("Refusées", refusees)
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
st.error(f"Erreur lors du chargement des statistiques : {e}")
|
database/db_connection.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine, text
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# Récupération de la connection string
|
| 9 |
+
DATABASE_URL = os.getenv("NEON_DB")
|
| 10 |
+
|
| 11 |
+
# Création de l'engine SQLAlchemy
|
| 12 |
+
engine = create_engine(DATABASE_URL, echo=False)
|
| 13 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 14 |
+
|
| 15 |
+
def get_db_session():
|
| 16 |
+
"""Retourne une session SQLAlchemy"""
|
| 17 |
+
return SessionLocal()
|
| 18 |
+
|
| 19 |
+
def init_database():
|
| 20 |
+
"""Initialise les tables si elles n'existent pas"""
|
| 21 |
+
with engine.connect() as conn:
|
| 22 |
+
# Table users
|
| 23 |
+
conn.execute(text("""
|
| 24 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 25 |
+
user_id SERIAL PRIMARY KEY,
|
| 26 |
+
username VARCHAR(50) UNIQUE NOT NULL,
|
| 27 |
+
password_hash VARCHAR(255) NOT NULL,
|
| 28 |
+
created_at TIMESTAMP DEFAULT NOW()
|
| 29 |
+
);
|
| 30 |
+
"""))
|
| 31 |
+
|
| 32 |
+
# Table applications
|
| 33 |
+
conn.execute(text("""
|
| 34 |
+
CREATE TABLE IF NOT EXISTS applications (
|
| 35 |
+
app_id SERIAL PRIMARY KEY,
|
| 36 |
+
user_id INT REFERENCES users(user_id) ON DELETE CASCADE,
|
| 37 |
+
company VARCHAR(255) NOT NULL,
|
| 38 |
+
description TEXT,
|
| 39 |
+
application_date DATE NOT NULL,
|
| 40 |
+
end_date DATE,
|
| 41 |
+
expected_delay_days INT,
|
| 42 |
+
status VARCHAR(50) DEFAULT 'En attente',
|
| 43 |
+
created_at TIMESTAMP DEFAULT NOW(),
|
| 44 |
+
updated_at TIMESTAMP DEFAULT NOW()
|
| 45 |
+
);
|
| 46 |
+
"""))
|
| 47 |
+
|
| 48 |
+
conn.commit()
|
database/init.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script d'initialisation de la base de données NeonDB
|
| 3 |
+
À lancer UNE SEULE FOIS avant le premier déploiement
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from db_connection import init_database
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
print("🔧 Initialisation de la base de données NeonDB...")
|
| 13 |
+
|
| 14 |
+
# Vérification de la connection string
|
| 15 |
+
if not os.getenv("NEON_DB"):
|
| 16 |
+
print("❌ Erreur: NEON_DB n'est pas défini dans le fichier .env")
|
| 17 |
+
exit(1)
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
init_database()
|
| 21 |
+
print("✅ Tables créées avec succès !")
|
| 22 |
+
print(" - Table 'users' créée")
|
| 23 |
+
print(" - Table 'applications' créée")
|
| 24 |
+
print("\n🎉 Base de données prête à l'emploi !")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"❌ Erreur lors de l'initialisation : {e}")
|
| 27 |
+
exit(1)
|
database/queries.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from database.db_connection import get_db_session
|
| 2 |
+
from sqlalchemy import text
|
| 3 |
+
|
| 4 |
+
# ============ USER QUERIES ============
|
| 5 |
+
|
| 6 |
+
def create_user(username, password_hash):
|
| 7 |
+
"""Crée un nouvel utilisateur"""
|
| 8 |
+
session = get_db_session()
|
| 9 |
+
try:
|
| 10 |
+
result = session.execute(
|
| 11 |
+
text("INSERT INTO users (username, password_hash) VALUES (:username, :password_hash) RETURNING user_id"),
|
| 12 |
+
{"username": username, "password_hash": password_hash}
|
| 13 |
+
)
|
| 14 |
+
user_id = result.fetchone()[0]
|
| 15 |
+
session.commit()
|
| 16 |
+
return user_id
|
| 17 |
+
except Exception as e:
|
| 18 |
+
session.rollback()
|
| 19 |
+
raise e
|
| 20 |
+
finally:
|
| 21 |
+
session.close()
|
| 22 |
+
|
| 23 |
+
def get_user_by_username(username):
|
| 24 |
+
"""Récupère un user par son username"""
|
| 25 |
+
session = get_db_session()
|
| 26 |
+
try:
|
| 27 |
+
result = session.execute(
|
| 28 |
+
text("SELECT * FROM users WHERE username = :username"),
|
| 29 |
+
{"username": username}
|
| 30 |
+
)
|
| 31 |
+
user = result.fetchone()
|
| 32 |
+
return dict(user._mapping) if user else None
|
| 33 |
+
finally:
|
| 34 |
+
session.close()
|
| 35 |
+
|
| 36 |
+
# ============ APPLICATION QUERIES ============
|
| 37 |
+
|
| 38 |
+
def create_application(user_id, company, description, application_date, end_date, expected_delay_days):
|
| 39 |
+
"""Crée une nouvelle candidature"""
|
| 40 |
+
session = get_db_session()
|
| 41 |
+
try:
|
| 42 |
+
result = session.execute(text("""
|
| 43 |
+
INSERT INTO applications
|
| 44 |
+
(user_id, company, description, application_date, end_date, expected_delay_days)
|
| 45 |
+
VALUES (:user_id, :company, :description, :application_date, :end_date, :expected_delay_days)
|
| 46 |
+
RETURNING app_id
|
| 47 |
+
"""), {
|
| 48 |
+
"user_id": user_id,
|
| 49 |
+
"company": company,
|
| 50 |
+
"description": description,
|
| 51 |
+
"application_date": application_date,
|
| 52 |
+
"end_date": end_date,
|
| 53 |
+
"expected_delay_days": expected_delay_days
|
| 54 |
+
})
|
| 55 |
+
app_id = result.fetchone()[0]
|
| 56 |
+
session.commit()
|
| 57 |
+
return app_id
|
| 58 |
+
except Exception as e:
|
| 59 |
+
session.rollback()
|
| 60 |
+
raise e
|
| 61 |
+
finally:
|
| 62 |
+
session.close()
|
| 63 |
+
|
| 64 |
+
def get_user_applications(user_id):
|
| 65 |
+
"""Récupère toutes les candidatures d'un user"""
|
| 66 |
+
session = get_db_session()
|
| 67 |
+
try:
|
| 68 |
+
result = session.execute(text("""
|
| 69 |
+
SELECT * FROM applications
|
| 70 |
+
WHERE user_id = :user_id
|
| 71 |
+
ORDER BY application_date DESC
|
| 72 |
+
"""), {"user_id": user_id})
|
| 73 |
+
applications = [dict(row._mapping) for row in result.fetchall()]
|
| 74 |
+
return applications
|
| 75 |
+
finally:
|
| 76 |
+
session.close()
|
| 77 |
+
|
| 78 |
+
def update_application(app_id, company, description, application_date, end_date, expected_delay_days, status):
|
| 79 |
+
"""Met à jour une candidature"""
|
| 80 |
+
session = get_db_session()
|
| 81 |
+
try:
|
| 82 |
+
session.execute(text("""
|
| 83 |
+
UPDATE applications
|
| 84 |
+
SET company = :company,
|
| 85 |
+
description = :description,
|
| 86 |
+
application_date = :application_date,
|
| 87 |
+
end_date = :end_date,
|
| 88 |
+
expected_delay_days = :expected_delay_days,
|
| 89 |
+
status = :status,
|
| 90 |
+
updated_at = NOW()
|
| 91 |
+
WHERE app_id = :app_id
|
| 92 |
+
"""), {
|
| 93 |
+
"company": company,
|
| 94 |
+
"description": description,
|
| 95 |
+
"application_date": application_date,
|
| 96 |
+
"end_date": end_date,
|
| 97 |
+
"expected_delay_days": expected_delay_days,
|
| 98 |
+
"status": status,
|
| 99 |
+
"app_id": app_id
|
| 100 |
+
})
|
| 101 |
+
session.commit()
|
| 102 |
+
except Exception as e:
|
| 103 |
+
session.rollback()
|
| 104 |
+
raise e
|
| 105 |
+
finally:
|
| 106 |
+
session.close()
|
| 107 |
+
|
| 108 |
+
def delete_application(app_id):
|
| 109 |
+
"""Supprime une candidature"""
|
| 110 |
+
session = get_db_session()
|
| 111 |
+
try:
|
| 112 |
+
session.execute(
|
| 113 |
+
text("DELETE FROM applications WHERE app_id = :app_id"),
|
| 114 |
+
{"app_id": app_id}
|
| 115 |
+
)
|
| 116 |
+
session.commit()
|
| 117 |
+
except Exception as e:
|
| 118 |
+
session.rollback()
|
| 119 |
+
raise e
|
| 120 |
+
finally:
|
| 121 |
+
session.close()
|
pages/1_📝_Nouvelle_candidature.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from datetime import date, timedelta
|
| 3 |
+
from utils.auth import is_logged_in
|
| 4 |
+
from database.queries import create_application
|
| 5 |
+
|
| 6 |
+
# Configuration de la page
|
| 7 |
+
st.set_page_config(
|
| 8 |
+
page_title="Nouvelle candidature",
|
| 9 |
+
page_icon="📝",
|
| 10 |
+
layout="wide"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
# Vérification de l'authentification
|
| 14 |
+
if not is_logged_in():
|
| 15 |
+
st.warning("⚠️ Veuillez vous connecter pour accéder à cette page")
|
| 16 |
+
st.stop()
|
| 17 |
+
|
| 18 |
+
# ============ PAGE PRINCIPALE ============
|
| 19 |
+
|
| 20 |
+
st.title("📝 Nouvelle candidature")
|
| 21 |
+
st.markdown("### Ajoutez une nouvelle candidature à votre suivi")
|
| 22 |
+
|
| 23 |
+
# Formulaire
|
| 24 |
+
with st.form("new_application_form", clear_on_submit=True):
|
| 25 |
+
col1, col2 = st.columns(2)
|
| 26 |
+
|
| 27 |
+
with col1:
|
| 28 |
+
company = st.text_input(
|
| 29 |
+
"Entreprise *",
|
| 30 |
+
placeholder="Ex: Google, Jedha, RATP...",
|
| 31 |
+
help="Nom de l'entreprise"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
application_date = st.date_input(
|
| 35 |
+
"Date de candidature *",
|
| 36 |
+
value=date.today(),
|
| 37 |
+
help="Date à laquelle vous avez envoyé votre candidature"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
expected_delay_days = st.number_input(
|
| 41 |
+
"Délai de réponse attendu (jours)",
|
| 42 |
+
min_value=1,
|
| 43 |
+
max_value=365,
|
| 44 |
+
value=14,
|
| 45 |
+
help="Nombre de jours avant une relance"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
with col2:
|
| 49 |
+
description = st.text_area(
|
| 50 |
+
"Description / Notes",
|
| 51 |
+
placeholder="Poste, source de l'annonce, contact, notes personnelles...",
|
| 52 |
+
height=100,
|
| 53 |
+
help="Informations complémentaires sur cette candidature"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
end_date = st.date_input(
|
| 57 |
+
"Date de fin (optionnel)",
|
| 58 |
+
value=None,
|
| 59 |
+
help="Date de clôture de la candidature (si connue)"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
st.markdown("---")
|
| 63 |
+
|
| 64 |
+
submitted = st.form_submit_button("✅ Enregistrer la candidature", type="primary", use_container_width=True)
|
| 65 |
+
|
| 66 |
+
if submitted:
|
| 67 |
+
# Validation
|
| 68 |
+
if not company:
|
| 69 |
+
st.error("⚠️ Le nom de l'entreprise est obligatoire")
|
| 70 |
+
elif not application_date:
|
| 71 |
+
st.error("⚠️ La date de candidature est obligatoire")
|
| 72 |
+
else:
|
| 73 |
+
try:
|
| 74 |
+
# Création de la candidature
|
| 75 |
+
app_id = create_application(
|
| 76 |
+
user_id=st.session_state['user_id'],
|
| 77 |
+
company=company,
|
| 78 |
+
description=description,
|
| 79 |
+
application_date=application_date,
|
| 80 |
+
end_date=end_date,
|
| 81 |
+
expected_delay_days=expected_delay_days
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
st.success(f"✅ Candidature chez **{company}** enregistrée avec succès !")
|
| 85 |
+
st.balloons()
|
| 86 |
+
|
| 87 |
+
# Info sur la date de relance
|
| 88 |
+
relance_date = application_date + timedelta(days=expected_delay_days)
|
| 89 |
+
st.info(f"📅 Date de relance suggérée : **{relance_date.strftime('%d/%m/%Y')}**")
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
st.error(f"❌ Erreur lors de l'enregistrement : {e}")
|
| 93 |
+
|
| 94 |
+
# ============ SECTION AIDE ============
|
| 95 |
+
|
| 96 |
+
st.markdown("---")
|
| 97 |
+
|
| 98 |
+
with st.expander("💡 Conseils pour bien suivre vos candidatures"):
|
| 99 |
+
st.markdown("""
|
| 100 |
+
**Bonnes pratiques :**
|
| 101 |
+
|
| 102 |
+
- 📋 **Description détaillée** : Notez le poste exact, le lien vers l'annonce, le nom du recruteur
|
| 103 |
+
- ⏰ **Délai réaliste** : 7-14 jours pour une startup, 14-30 jours pour une grande entreprise
|
| 104 |
+
- 🔔 **Relance** : Passé le délai, n'hésitez pas à relancer poliment
|
| 105 |
+
- 📊 **Statut** : Mettez à jour régulièrement le statut dans "Mes candidatures"
|
| 106 |
+
|
| 107 |
+
**Exemples de notes utiles :**
|
| 108 |
+
- "Candidature via LinkedIn - Contact : Jean Dupont (RH)"
|
| 109 |
+
- "Référé par Marie (ancienne collègue)"
|
| 110 |
+
- "Poste : Data Scientist Senior - Ref: DS-2025-01"
|
| 111 |
+
- "Entretien téléphonique prévu le 25/02"
|
| 112 |
+
""")
|
pages/2_📊_Mes_candidatures.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from datetime import date, timedelta
|
| 4 |
+
from utils.auth import is_logged_in
|
| 5 |
+
from queries import get_user_applications, update_application, delete_application
|
| 6 |
+
|
| 7 |
+
# Configuration de la page
|
| 8 |
+
st.set_page_config(
|
| 9 |
+
page_title="Mes candidatures",
|
| 10 |
+
page_icon="📊",
|
| 11 |
+
layout="wide"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# Vérification de l'authentification
|
| 15 |
+
if not is_logged_in():
|
| 16 |
+
st.warning("⚠️ Veuillez vous connecter pour accéder à cette page")
|
| 17 |
+
st.stop()
|
| 18 |
+
|
| 19 |
+
# ============ PAGE PRINCIPALE ============
|
| 20 |
+
|
| 21 |
+
st.title("📊 Mes candidatures")
|
| 22 |
+
st.markdown("### Vue d'ensemble de toutes vos candidatures")
|
| 23 |
+
|
| 24 |
+
# Chargement des candidatures
|
| 25 |
+
try:
|
| 26 |
+
applications = get_user_applications(st.session_state['user_id'])
|
| 27 |
+
except Exception as e:
|
| 28 |
+
st.error(f"❌ Erreur lors du chargement des candidatures : {e}")
|
| 29 |
+
st.stop()
|
| 30 |
+
|
| 31 |
+
# ============ STATISTIQUES ============
|
| 32 |
+
|
| 33 |
+
if applications:
|
| 34 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 35 |
+
|
| 36 |
+
total = len(applications)
|
| 37 |
+
en_attente = len([app for app in applications if app['status'] == 'En attente'])
|
| 38 |
+
relance = len([app for app in applications if app['status'] == 'Relancé'])
|
| 39 |
+
accepte = len([app for app in applications if app['status'] == 'Accepté'])
|
| 40 |
+
refuse = len([app for app in applications if app['status'] == 'Refusé'])
|
| 41 |
+
|
| 42 |
+
with col1:
|
| 43 |
+
st.metric("📋 Total", total)
|
| 44 |
+
with col2:
|
| 45 |
+
st.metric("⏳ En attente", en_attente)
|
| 46 |
+
with col3:
|
| 47 |
+
st.metric("✅ Acceptées", accepte)
|
| 48 |
+
with col4:
|
| 49 |
+
st.metric("❌ Refusées", refuse)
|
| 50 |
+
|
| 51 |
+
st.markdown("---")
|
| 52 |
+
|
| 53 |
+
# ============ FILTRES ============
|
| 54 |
+
|
| 55 |
+
if applications:
|
| 56 |
+
col1, col2, col3 = st.columns(3)
|
| 57 |
+
|
| 58 |
+
with col1:
|
| 59 |
+
status_filter = st.multiselect(
|
| 60 |
+
"Filtrer par statut",
|
| 61 |
+
options=["En attente", "Relancé", "Accepté", "Refusé"],
|
| 62 |
+
default=["En attente", "Relancé", "Accepté", "Refusé"]
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
with col2:
|
| 66 |
+
search_company = st.text_input("🔍 Rechercher une entreprise", placeholder="Nom de l'entreprise...")
|
| 67 |
+
|
| 68 |
+
with col3:
|
| 69 |
+
sort_by = st.selectbox(
|
| 70 |
+
"Trier par",
|
| 71 |
+
options=["Date de candidature (récent)", "Date de candidature (ancien)", "Entreprise (A-Z)", "Statut"]
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
st.markdown("---")
|
| 75 |
+
|
| 76 |
+
# ============ AFFICHAGE DES CANDIDATURES ============
|
| 77 |
+
|
| 78 |
+
if not applications:
|
| 79 |
+
st.info("📭 Aucune candidature enregistrée pour le moment")
|
| 80 |
+
st.markdown("👉 Rendez-vous sur la page **Nouvelle candidature** pour commencer !")
|
| 81 |
+
else:
|
| 82 |
+
# Filtrage
|
| 83 |
+
filtered_apps = [app for app in applications if app['status'] in status_filter]
|
| 84 |
+
|
| 85 |
+
if search_company:
|
| 86 |
+
filtered_apps = [app for app in filtered_apps if search_company.lower() in app['company'].lower()]
|
| 87 |
+
|
| 88 |
+
# Tri
|
| 89 |
+
if sort_by == "Date de candidature (récent)":
|
| 90 |
+
filtered_apps = sorted(filtered_apps, key=lambda x: x['application_date'], reverse=True)
|
| 91 |
+
elif sort_by == "Date de candidature (ancien)":
|
| 92 |
+
filtered_apps = sorted(filtered_apps, key=lambda x: x['application_date'])
|
| 93 |
+
elif sort_by == "Entreprise (A-Z)":
|
| 94 |
+
filtered_apps = sorted(filtered_apps, key=lambda x: x['company'])
|
| 95 |
+
elif sort_by == "Statut":
|
| 96 |
+
filtered_apps = sorted(filtered_apps, key=lambda x: x['status'])
|
| 97 |
+
|
| 98 |
+
# Affichage
|
| 99 |
+
if not filtered_apps:
|
| 100 |
+
st.warning("Aucune candidature ne correspond à vos filtres")
|
| 101 |
+
else:
|
| 102 |
+
st.markdown(f"**{len(filtered_apps)} candidature(s) trouvée(s)**")
|
| 103 |
+
|
| 104 |
+
for app in filtered_apps:
|
| 105 |
+
# Calcul de la date de relance suggérée
|
| 106 |
+
relance_date = app['application_date'] + timedelta(days=app['expected_delay_days'] or 14)
|
| 107 |
+
days_since_application = (date.today() - app['application_date']).days
|
| 108 |
+
|
| 109 |
+
# Badge de statut
|
| 110 |
+
status_colors = {
|
| 111 |
+
"En attente": "🟡",
|
| 112 |
+
"Relancé": "🔵",
|
| 113 |
+
"Accepté": "🟢",
|
| 114 |
+
"Refusé": "🔴"
|
| 115 |
+
}
|
| 116 |
+
status_badge = status_colors.get(app['status'], "⚪")
|
| 117 |
+
|
| 118 |
+
with st.expander(f"{status_badge} **{app['company']}** - {app['application_date'].strftime('%d/%m/%Y')}", expanded=False):
|
| 119 |
+
col1, col2 = st.columns([2, 1])
|
| 120 |
+
|
| 121 |
+
with col1:
|
| 122 |
+
st.markdown(f"**Description :**")
|
| 123 |
+
st.text(app['description'] or "Aucune description")
|
| 124 |
+
|
| 125 |
+
st.markdown(f"**📅 Date de candidature :** {app['application_date'].strftime('%d/%m/%Y')} ({days_since_application} jours)")
|
| 126 |
+
|
| 127 |
+
if app['end_date']:
|
| 128 |
+
st.markdown(f"**🏁 Date de fin :** {app['end_date'].strftime('%d/%m/%Y')}")
|
| 129 |
+
|
| 130 |
+
st.markdown(f"**⏰ Délai attendu :** {app['expected_delay_days']} jours")
|
| 131 |
+
st.markdown(f"**📆 Relance suggérée :** {relance_date.strftime('%d/%m/%Y')}")
|
| 132 |
+
|
| 133 |
+
# Alerte si délai dépassé
|
| 134 |
+
if app['status'] == "En attente" and date.today() > relance_date:
|
| 135 |
+
days_overdue = (date.today() - relance_date).days
|
| 136 |
+
st.warning(f"⚠️ Délai dépassé de {days_overdue} jour(s) - Pensez à relancer !")
|
| 137 |
+
|
| 138 |
+
with col2:
|
| 139 |
+
st.markdown("**Actions**")
|
| 140 |
+
|
| 141 |
+
# Modification du statut
|
| 142 |
+
new_status = st.selectbox(
|
| 143 |
+
"Statut",
|
| 144 |
+
options=["En attente", "Relancé", "Accepté", "Refusé"],
|
| 145 |
+
index=["En attente", "Relancé", "Accepté", "Refusé"].index(app['status']),
|
| 146 |
+
key=f"status_{app['app_id']}"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
col_btn1, col_btn2 = st.columns(2)
|
| 150 |
+
|
| 151 |
+
with col_btn1:
|
| 152 |
+
if st.button("💾 Sauvegarder", key=f"save_{app['app_id']}", type="primary", use_container_width=True):
|
| 153 |
+
try:
|
| 154 |
+
update_application(
|
| 155 |
+
app_id=app['app_id'],
|
| 156 |
+
company=app['company'],
|
| 157 |
+
description=app['description'],
|
| 158 |
+
application_date=app['application_date'],
|
| 159 |
+
end_date=app['end_date'],
|
| 160 |
+
expected_delay_days=app['expected_delay_days'],
|
| 161 |
+
status=new_status
|
| 162 |
+
)
|
| 163 |
+
st.success("✅ Mis à jour !")
|
| 164 |
+
st.rerun()
|
| 165 |
+
except Exception as e:
|
| 166 |
+
st.error(f"Erreur : {e}")
|
| 167 |
+
|
| 168 |
+
with col_btn2:
|
| 169 |
+
if st.button("🗑️ Supprimer", key=f"delete_{app['app_id']}", type="secondary", use_container_width=True):
|
| 170 |
+
try:
|
| 171 |
+
delete_application(app['app_id'])
|
| 172 |
+
st.success("🗑️ Supprimée !")
|
| 173 |
+
st.rerun()
|
| 174 |
+
except Exception as e:
|
| 175 |
+
st.error(f"Erreur : {e}")
|
| 176 |
+
|
| 177 |
+
# ============ EXPORT ============
|
| 178 |
+
|
| 179 |
+
if applications:
|
| 180 |
+
st.markdown("---")
|
| 181 |
+
|
| 182 |
+
# Conversion en DataFrame pour export
|
| 183 |
+
df = pd.DataFrame(applications)
|
| 184 |
+
df['application_date'] = pd.to_datetime(df['application_date']).dt.strftime('%d/%m/%Y')
|
| 185 |
+
df['end_date'] = pd.to_datetime(df['end_date']).dt.strftime('%d/%m/%Y')
|
| 186 |
+
|
| 187 |
+
csv = df.to_csv(index=False).encode('utf-8')
|
| 188 |
+
|
| 189 |
+
st.download_button(
|
| 190 |
+
label="📥 Exporter en CSV",
|
| 191 |
+
data=csv,
|
| 192 |
+
file_name=f"candidatures_{st.session_state['username']}_{date.today()}.csv",
|
| 193 |
+
mime="text/csv"
|
| 194 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
sqlalchemy
|
| 3 |
+
psycopg2-binary
|
| 4 |
+
python-dotenv
|
| 5 |
+
bcrypt
|
| 6 |
+
pandas
|
| 7 |
+
|
| 8 |
+
huggingface-hub
|
| 9 |
+
|
| 10 |
+
# Dev dependencies (pour CI uniquement)
|
| 11 |
+
pytest
|
| 12 |
+
pytest-cov
|
| 13 |
+
flake8
|
utils/auth.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from database.queries import get_user_by_username, create_user
|
| 4 |
+
|
| 5 |
+
def hash_password(password):
|
| 6 |
+
"""Hash un mot de passe"""
|
| 7 |
+
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 8 |
+
|
| 9 |
+
def verify_password(password, password_hash):
|
| 10 |
+
"""Vérifie un mot de passe"""
|
| 11 |
+
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
| 12 |
+
|
| 13 |
+
def login(username, password):
|
| 14 |
+
"""Authentifie un utilisateur"""
|
| 15 |
+
user = get_user_by_username(username)
|
| 16 |
+
if user and verify_password(password, user['password_hash']):
|
| 17 |
+
st.session_state['logged_in'] = True
|
| 18 |
+
st.session_state['user_id'] = user['user_id']
|
| 19 |
+
st.session_state['username'] = user['username']
|
| 20 |
+
return True
|
| 21 |
+
return False
|
| 22 |
+
|
| 23 |
+
def logout():
|
| 24 |
+
"""Déconnecte l'utilisateur"""
|
| 25 |
+
st.session_state['logged_in'] = False
|
| 26 |
+
st.session_state['user_id'] = None
|
| 27 |
+
st.session_state['username'] = None
|
| 28 |
+
|
| 29 |
+
def register(username, password):
|
| 30 |
+
"""Enregistre un nouvel utilisateur"""
|
| 31 |
+
try:
|
| 32 |
+
password_hash = hash_password(password)
|
| 33 |
+
user_id = create_user(username, password_hash)
|
| 34 |
+
return True, "Compte créé avec succès !"
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return False, f"Erreur: {str(e)}"
|
| 37 |
+
|
| 38 |
+
def is_logged_in():
|
| 39 |
+
"""Vérifie si l'utilisateur est connecté"""
|
| 40 |
+
return st.session_state.get('logged_in', False)
|