From 6e21d52fb4899a5c24908ff3ffeb30e32a299f79 Mon Sep 17 00:00:00 2001 From: Steventog Date: Wed, 3 Jun 2026 23:15:58 +0000 Subject: [PATCH 1/2] feat: add job board page --- .gitignore | 80 +++++-- app/database/config.py | 6 +- app/routers/router_2025.py | 20 +- app/routers/router_2026.py | 29 +++ app/static/2026/css/pages/jobs.css | 318 +++++++++++++++++++++++++ app/templates/2026/2026_jobs.html | 370 +++++++++++++++++++++++++++++ app/templates/2026/base.html | 6 +- 7 files changed, 789 insertions(+), 40 deletions(-) create mode 100644 app/static/2026/css/pages/jobs.css create mode 100644 app/templates/2026/2026_jobs.html diff --git a/.gitignore b/.gitignore index cc59e7a..f0966d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,70 @@ -__pycache__ +# Python +__pycache__/ *.py[cod] *.pyo *.pyd -*.egg-info +*.so *.egg -*.whl +*.egg-info/ +dist/ +build/ + +# Environnements virtuels +.venv/ +venv/ +env/ +ENV/ + +# Variables d'environnement — ne jamais commiter +.env +!.env.example *.env -*.venv +!*.env.example + +# Secrets +secret* +*.key +*.pem + +# Bases de données locales *.db *.sqlite3 + +# Logs *.log -*.DS_Store -*.coverage -*.mypy_cache -*.pytest_cache -*.tox -*.coverage.* -*.hypothesis -*.coverage.* -*.coverage.* -*.coverage.* -*.coverage.* +logs/ + +# IDEs +.vscode/ +.idea/ +*.iml +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Tests et données mock +.coverage +.pytest_cache/ +.mypy_cache/ +.tox/ +htmlcov/ +coverage.xml +mock* +fake* fake_data.py -QRCODE -env test.py -pycontg25 -*cache* +test* + +# Assets générés +QRCODE/ *.bak *.tmp *.swp *.swo -*.idea -.vscode -*.iml -test.py -test* -*.sublime-project -db.sqlite3 *.sql .gstack/ +pycontg25/ diff --git a/app/database/config.py b/app/database/config.py index 39f6534..050037f 100644 --- a/app/database/config.py +++ b/app/database/config.py @@ -4,7 +4,7 @@ load_dotenv() -url: str = os.environ.get("SUPABASE_URL") -key: str = os.environ.get("SUPABASE_KEY") +url: str = os.environ.get("SUPABASE_URL", "") +key: str = os.environ.get("SUPABASE_KEY", "") -supabase: Client = create_client(url, key) \ No newline at end of file +supabase: Client | None = create_client(url, key) if url and key else None diff --git a/app/routers/router_2025.py b/app/routers/router_2025.py index 99738f3..1e740c4 100644 --- a/app/routers/router_2025.py +++ b/app/routers/router_2025.py @@ -60,18 +60,18 @@ 2025, 6, 30, 16).strftime("%d %B %Y at %H:%M UTC") -API_ROOT = os.getenv("API_ROOT", "http://127.0.0.1:8000/api") -speakers_list = requests.get(f"{API_ROOT}/speakers") -if speakers_list.status_code == 200: - speakers_list = speakers_list.json() -else: - speakers_list = [] +API_ROOT = os.getenv("API_ROOT", "") +try: + _r = requests.get(f"{API_ROOT}/speakers", timeout=5) if API_ROOT else None + speakers_list = _r.json() if _r and _r.status_code == 200 else [] +except Exception: + speakers_list = [] -paidsponsors = requests.get(f"{API_ROOT}/sponsors") -if paidsponsors.status_code == 200: - paidsponsors = paidsponsors.json() -else: +try: + _r = requests.get(f"{API_ROOT}/sponsors", timeout=5) if API_ROOT else None + paidsponsors = _r.json() if _r and _r.status_code == 200 else [] +except Exception: paidsponsors = [] diff --git a/app/routers/router_2026.py b/app/routers/router_2026.py index a7e0ec2..3817159 100644 --- a/app/routers/router_2026.py +++ b/app/routers/router_2026.py @@ -1474,6 +1474,35 @@ def shop(request: Request): pass +async def _fetch_job_offers() -> list[dict]: + event_code = getattr(settings, "python_togo_event_code", None) + if not event_code: + return [] + headers = {"Authorization": f"Bearer {settings.python_togo_api_key}"} + url = _build_api_url(f"/job-offers/list/{event_code}") + try: + async with httpx.AsyncClient(timeout=settings.python_togo_api_timeout_seconds) as client: + response = await client.get(url, headers=headers) + if response.status_code < 400: + return _extract_partner_rows(response.json()) + except Exception: + return [] + return [] + + +@router.get("/jobs") +async def jobs(request: Request): + job_offers = await _fetch_job_offers() + return await _render_page_with_event( + request=request, + name="2026_jobs.html", + active_page="jobs", + page_css="jobs.css", + page_title="PyCon Togo 2026 — Job Board", + extra_context={"job_offers": job_offers}, + ) + + @router.get("/30daysofpython") def _30daysofpython(request: Request): return RedirectResponse(url="https://fata.app/challenge/pycon-togo-2026", status_code=302) diff --git a/app/static/2026/css/pages/jobs.css b/app/static/2026/css/pages/jobs.css new file mode 100644 index 0000000..8394dbb --- /dev/null +++ b/app/static/2026/css/pages/jobs.css @@ -0,0 +1,318 @@ +/* Jobs */ + +/* ── FILTRES ──────────────────────────────────────────────── */ +.jobs-filters-section { + padding: 0; + background: #f4f8f7; + border-bottom: 1px solid #e2ede9; +} + +.jobs-filters { + display: flex; + flex-wrap: wrap; + gap: 24px; + padding: 20px 0; +} + +.filter-group { display: flex; flex-direction: column; gap: 10px; } + +.filter-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c757d; +} + +.filter-btns { display: flex; flex-wrap: wrap; gap: 8px; } + +.filter-btn { + padding: 6px 16px; + border-radius: 20px; + border: 1.5px solid #c9dbd5; + background: transparent; + color: #444; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; + font-family: var(--font-sans, 'Inter', sans-serif); +} +.filter-btn:hover { border-color: var(--primary-color, #056d1e); color: var(--primary-color, #056d1e); } +.filter-btn.active { background: var(--primary-color, #056d1e); border-color: var(--primary-color, #056d1e); color: #fff; } + +/* ── SECTION PRINCIPALE ───────────────────────────────────── */ +.jobs-section { padding-top: 40px; } + +/* ── SPLIT CONTAINER ──────────────────────────────────────── */ +.jobs-container { + display: flex; + align-items: flex-start; + gap: 0; +} + +/* Sidebar */ +.jobs-sidebar { + flex: 1 1 100%; + min-width: 0; + transition: flex 0.35s cubic-bezier(.4,0,.2,1), + max-width 0.35s cubic-bezier(.4,0,.2,1); +} + +/* Panneau de détail — fermé */ +.jobs-detail-panel { + flex: 0 0 0; + max-width: 0; + overflow: hidden; + opacity: 0; + transition: flex 0.35s cubic-bezier(.4,0,.2,1), + max-width 0.35s cubic-bezier(.4,0,.2,1), + opacity 0.25s ease; + position: relative; +} + +/* Mode split activé */ +.jobs-container.has-selection .jobs-sidebar { + flex: 0 0 380px; + max-width: 380px; +} + +.jobs-container.has-selection .jobs-detail-panel { + flex: 1 1 auto; + max-width: calc(100% - 380px - 28px); + overflow: visible; + margin-left: 28px; +} + +.jobs-detail-panel.is-visible { + opacity: 1; +} + +/* ── GRILLE DE CARDS ──────────────────────────────────────── */ +.jobs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + transition: grid-template-columns 0.3s; +} + +/* Mode split : 1 colonne */ +.jobs-container.has-selection .jobs-grid { + grid-template-columns: 1fr; + gap: 0; +} + +/* ── CARD ─────────────────────────────────────────────────── */ +.job-card { + background: #fff; + border: 1px solid #e2ede9; + border-radius: 14px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, + border-color 0.2s ease, border-radius 0.25s ease, + background 0.2s ease; + overflow: hidden; +} + +/* Vue grille */ +.job-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 28px rgba(5,109,30,.12); + border-color: #a8d5b9; +} + +.job-card-grid-view { padding: 24px; display: flex; flex-direction: column; gap: 14px; } +.job-card-compact-view { display: none; } + +/* Vue compacte (mode split) */ +.jobs-container.has-selection .job-card { + border-radius: 0; + border-left: none; + border-right: none; + border-top: none; + transform: none !important; + box-shadow: none !important; +} +.jobs-container.has-selection .job-card:first-child { border-radius: 10px 10px 0 0; } +.jobs-container.has-selection .job-card:last-child { border-radius: 0 0 10px 10px; border-bottom: 1px solid #e2ede9; } + +.jobs-container.has-selection .job-card-grid-view { display: none; } +.jobs-container.has-selection .job-card-compact-view { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; +} + +/* Card sélectionnée */ +.jobs-container.has-selection .job-card:hover { + background: #f4f8f7; +} +.job-card.is-selected { + background: #f0f8f2 !important; + border-left: 3px solid var(--primary-color, #056d1e) !important; +} + +/* ── ÉLÉMENTS VUE GRILLE ──────────────────────────────────── */ +.job-card-header { display: flex; align-items: center; gap: 16px; } + +.job-company-logo { + width: 56px; height: 56px; + object-fit: contain; border-radius: 10px; + border: 1px solid #e9ecef; background: #f8f9fa; + flex-shrink: 0; +} +.job-company-logo-fallback { + width: 56px; height: 56px; border-radius: 10px; + background: #edf4f2; color: var(--primary-color, #056d1e); + font-size: 1.1rem; font-weight: 800; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; border: 1px solid #c9dbd5; +} +.job-meta { min-width: 0; } +.job-title { font-size: 1.05rem; font-weight: 700; color: #1d2630; line-height: 1.3; margin: 0 0 4px; } +.job-company { font-size: 0.85rem; color: #6c757d; font-weight: 500; } + +.job-badges { display: flex; flex-wrap: wrap; gap: 8px; } + +.badge { + display: inline-flex; align-items: center; + padding: 4px 12px; border-radius: 20px; + font-size: 0.78rem; font-weight: 600; line-height: 1; +} +.badge-location.badge-remote { background: #e8f4fd; color: #1a73e8; } +.badge-location.badge-hybrid { background: #fff3cd; color: #856404; } +.badge-location.badge-onsite { background: #d4edda; color: #155724; } +.badge-contract { background: #f0f0f0; color: #4a4a4a; } +.badge-salary { background: #fef9ec; color: #8a6800; border: 1px solid #f5d76e; } + +.job-description { font-size: 0.9rem; color: #555; line-height: 1.65; flex-grow: 1; margin: 0; } + +.job-tags { display: flex; flex-wrap: wrap; gap: 6px; } +.job-tag { font-size: 0.75rem; font-weight: 500; padding: 3px 10px; border-radius: 6px; background: #edf4f2; color: #056d1e; border: 1px solid #c9dbd5; } + +.job-card-footer { display: flex; align-items: center; gap: 6px; margin-top: auto; padding-top: 8px; border-top: 1px solid #f0f0f0; } +.job-discover { font-size: 0.82rem; font-weight: 600; color: var(--primary-color, #056d1e); text-transform: uppercase; letter-spacing: 0.05em; } +.job-discover-arrow { color: var(--primary-color, #056d1e); font-size: 1rem; } + +/* ── VUE COMPACTE ─────────────────────────────────────────── */ +.job-logo-sm { + width: 44px; height: 44px; + object-fit: contain; border-radius: 8px; + border: 1px solid #e9ecef; background: #f8f9fa; + flex-shrink: 0; +} +.job-logo-sm-fallback { + width: 44px; height: 44px; border-radius: 8px; + background: #edf4f2; color: #056d1e; + font-size: 0.9rem; font-weight: 800; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; border: 1px solid #c9dbd5; +} +.job-compact-meta { flex: 1; min-width: 0; } +.job-compact-title { font-size: 0.92rem; font-weight: 700; color: #1d2630; margin: 0 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.job-compact-company { font-size: 0.8rem; color: #6c757d; } +.job-compact-arrow { font-size: 1rem; color: #c9dbd5; flex-shrink: 0; transition: color 0.2s, transform 0.2s; } +.job-card:hover .job-compact-arrow, +.job-card.is-selected .job-compact-arrow { color: var(--primary-color, #056d1e); transform: translateX(3px); } + +/* ── PANNEAU DE DÉTAIL ────────────────────────────────────── */ +.jobs-detail-panel { + background: #fff; + border: 1px solid #e2ede9; + border-radius: 14px; + padding: 0; +} + +.jobs-detail-panel.is-visible { + padding: 32px; +} + +.panel-close-btn { + position: absolute; + top: 16px; right: 16px; + background: #f4f8f7; + border: 1px solid #e2ede9; + border-radius: 50%; + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + font-size: 0.9rem; + color: #6c757d; + transition: background 0.2s, color 0.2s; + opacity: 0; + transition: opacity 0.2s; +} +.jobs-detail-panel.is-visible .panel-close-btn { opacity: 1; } +.panel-close-btn:hover { background: #fdecea; color: #c0392b; border-color: #f5c6c2; } + +.panel-body { opacity: 0; transition: opacity 0.2s ease 0.15s; } +.jobs-detail-panel.is-visible .panel-body { opacity: 1; } + +.panel-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 16px; } + +.panel-logo { + width: 64px; height: 64px; + object-fit: contain; border-radius: 12px; + border: 1px solid #e9ecef; background: #f8f9fa; + flex-shrink: 0; +} +.panel-logo-fallback { + width: 64px; height: 64px; border-radius: 12px; + background: #edf4f2; color: #056d1e; + font-size: 1.2rem; font-weight: 800; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; border: 1px solid #c9dbd5; +} +.panel-title { font-size: 1.4rem; font-weight: 800; color: #1d2630; margin: 0 0 4px; line-height: 1.2; } +.panel-company { font-size: 0.9rem; color: #6c757d; font-weight: 500; } + +.panel-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; } + +.panel-apply-btn { display: inline-block; margin-bottom: 24px; } + +.panel-divider { border: none; border-top: 1px solid #e9ecef; margin: 0 0 20px; } + +.panel-section-title { font-size: 1.1rem; font-weight: 700; color: #1d2630; margin: 0 0 12px; } + +.panel-description { font-size: 0.92rem; color: #444; line-height: 1.75; margin-bottom: 16px; white-space: pre-line; } + +.panel-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; } + +.panel-deadline { font-size: 0.85rem; color: #856404; background: #fff3cd; border-radius: 8px; padding: 8px 12px; margin-top: 8px; } + +/* ── ÉTAT VIDE ────────────────────────────────────────────── */ +.jobs-empty-filter { text-align: center; padding: 48px 24px; color: #6c757d; } +.jobs-empty { text-align: center; padding: 80px 24px; max-width: 480px; margin: 0 auto; } +.jobs-empty-icon { font-size: 3.5rem; margin-bottom: 16px; } +.jobs-empty h2 { font-size: 1.5rem; color: #1d2630; margin-bottom: 12px; } +.jobs-empty p { color: #6c757d; line-height: 1.6; } + +/* ── CTA ──────────────────────────────────────────────────── */ +.jobs-cta-section { background: #f4f8f7; border-top: 1px solid #e2ede9; } +.jobs-cta { text-align: center; max-width: 600px; margin: 0 auto; padding: 16px 0; display: flex; flex-direction: column; align-items: center; gap: 16px; } +.jobs-cta h2 { font-size: 1.7rem; color: #1d2630; margin: 0; } +.jobs-cta p { color: #555; line-height: 1.6; margin: 0; } + +/* ── RESPONSIVE ───────────────────────────────────────────── */ +@media (max-width: 900px) { + .jobs-container.has-selection .jobs-sidebar { flex: 0 0 300px; max-width: 300px; } + .jobs-container.has-selection .jobs-detail-panel { max-width: calc(100% - 300px - 20px); margin-left: 20px; } +} + +@media (max-width: 768px) { + .jobs-container { flex-direction: column; } + + .jobs-container.has-selection .jobs-sidebar { + flex: 0 0 auto; + max-width: 100%; + width: 100%; + } + .jobs-container.has-selection .jobs-detail-panel { + flex: 0 0 auto; + max-width: 100%; + width: 100%; + margin-left: 0; + margin-top: 16px; + } +} diff --git a/app/templates/2026/2026_jobs.html b/app/templates/2026/2026_jobs.html new file mode 100644 index 0000000..27e1821 --- /dev/null +++ b/app/templates/2026/2026_jobs.html @@ -0,0 +1,370 @@ +{% extends "base.html" %} + +{% block content %} +
+ + +
+
+ Carrières +

Offres d'emploi

+

+ Consultez les opportunités partagées par nos partenaires et sponsors. +

+
+
+ + {% set active_offers = job_offers | selectattr('is_active') | list if job_offers else [] %} + + {% if active_offers %} + + +
+
+
+
+ Lieu +
+ + + + +
+
+
+ Contrat +
+ + + + + +
+
+
+
+
+ + +
+
+
+ + +
+
+ {% for job in active_offers %} +
+ + +
+
+ {% if job.logo_url %} + + {% else %} +
{{ job.company[:2] | upper }}
+ {% endif %} +
+

{{ job.title }}

+ {{ job.company }} +
+
+
+ {{ job.location | capitalize }} + {{ job.contract_type | replace('-',' ') | title }} + {% if job.salary_range %}{{ job.salary_range }}{% endif %} +
+

{{ job.description | truncate(140, True, '…') }}

+ {% if job.tags %} +
+ {% for tag in job.tags %}{{ tag }}{% endfor %} +
+ {% endif %} + +
+ + +
+ {% if job.logo_url %} + {{ job.company }} + {% else %} +
{{ job.company[:2] | upper }}
+ {% endif %} +
+

{{ job.title }}

+ {{ job.company }} +
+ +
+ +
+ {% endfor %} +
+ + +
+ + +
+ +
+
+ +
+
+
+ + + + + {% else %} + + +
+
+
+
💼
+

Aucune offre pour le moment

+

+ Nos partenaires n'ont pas encore publié d'offres. Revenez bientôt ! +

+
+
+
+ + {% endif %} + + +
+
+
+

Vous souhaitez publier une offre ?

+

+ Devenez partenaire et touchez des centaines de développeurs Python au Togo et en Afrique. +

+ Devenir Partenaire → +
+
+
+ +
+ + +{% endblock %} diff --git a/app/templates/2026/base.html b/app/templates/2026/base.html index e64260c..02bb7c0 100644 --- a/app/templates/2026/base.html +++ b/app/templates/2026/base.html @@ -42,7 +42,7 @@ Home Team + Job Board
From 72fffa565de9c223b876c0be06f7a184d1f31971 Mon Sep 17 00:00:00 2001 From: Steventog Date: Fri, 5 Jun 2026 07:59:11 +0000 Subject: [PATCH 2/2] feat: add job link on footer and update job page --- app/routers/router_2026.py | 5 +---- app/static/2026/css/pages/jobs.css | 1 + app/templates/2026/2026_jobs.html | 13 ++++++++++--- app/templates/2026/base.html | 1 + 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/routers/router_2026.py b/app/routers/router_2026.py index 3817159..1485909 100644 --- a/app/routers/router_2026.py +++ b/app/routers/router_2026.py @@ -1475,11 +1475,8 @@ def shop(request: Request): async def _fetch_job_offers() -> list[dict]: - event_code = getattr(settings, "python_togo_event_code", None) - if not event_code: - return [] headers = {"Authorization": f"Bearer {settings.python_togo_api_key}"} - url = _build_api_url(f"/job-offers/list/{event_code}") + url = _build_api_url("/job-offers/list/active") try: async with httpx.AsyncClient(timeout=settings.python_togo_api_timeout_seconds) as client: response = await client.get(url, headers=headers) diff --git a/app/static/2026/css/pages/jobs.css b/app/static/2026/css/pages/jobs.css index 8394dbb..c638f01 100644 --- a/app/static/2026/css/pages/jobs.css +++ b/app/static/2026/css/pages/jobs.css @@ -184,6 +184,7 @@ .badge-location.badge-hybrid { background: #fff3cd; color: #856404; } .badge-location.badge-onsite { background: #d4edda; color: #155724; } .badge-contract { background: #f0f0f0; color: #4a4a4a; } +.badge-country { background: #f0f4ff; color: #3730a3; border: 1px solid #c7d2fe; } .badge-salary { background: #fef9ec; color: #8a6800; border: 1px solid #f5d76e; } .job-description { font-size: 0.9rem; color: #555; line-height: 1.65; flex-grow: 1; margin: 0; } diff --git a/app/templates/2026/2026_jobs.html b/app/templates/2026/2026_jobs.html index 27e1821..cf8606e 100644 --- a/app/templates/2026/2026_jobs.html +++ b/app/templates/2026/2026_jobs.html @@ -88,7 +88,8 @@

{{ job.title }}

{{ job.location | capitalize }} {{ job.contract_type | replace('-',' ') | title }} - {% if job.salary_range %}{{ job.salary_range }}{% endif %} + {% if job.country %}{{ job.country }}{% endif %} + {% if job.salary_range and job.salary_range | lower not in ['non spécifié', 'non specifie', 'n/a', '-', ''] %}{{ job.salary_range }}{% endif %}

{{ job.description | truncate(140, True, '…') }}

{% if job.tags %} @@ -144,6 +145,7 @@

{{ job.title }}

"logo_url": {{ (job.logo_url or '') | tojson }}, "location": {{ job.location | tojson }}, "contract_type": {{ job.contract_type | tojson }}, + "country": {{ (job.country or '') | tojson }}, "description": {{ job.description | tojson }}, "apply_url": {{ job.apply_url | tojson }}, "salary_range": {{ (job.salary_range or '') | tojson }}, @@ -252,9 +254,13 @@

Date limite : ' + d + '

'; + deadlineHtml = '

Date limite : ' + d + '

'; } - var salaryHtml = job.salary_range + var countryHtml = job.country + ? ''+job.country+'' + : ''; + var _salary = (job.salary_range || '').trim().toLowerCase(); + var salaryHtml = (_salary && _salary !== 'non spécifié' && _salary !== 'non specifie' && _salary !== 'n/a' && _salary !== '-') ? ''+job.salary_range+'' : ''; var logoHtml = job.logo_url @@ -272,6 +278,7 @@

' + ''+badgeLocLabel(job.location)+'' + ''+badgeConLabel(job.contract_type)+'' + + countryHtml + salaryHtml + '

' + 'Navigation About Sponsors Team + Job Board Contact