Skip to content

Commit b1642c2

Browse files
committed
Add Nginx configuration and StatusPage component
- Created a new Nginx configuration file to handle API proxying and serve static assets. - Implemented a StatusPage component to display platform status, including active events, total events, and data sources. - Integrated loading states and error handling for API calls in the StatusPage.
1 parent 89b4b7b commit b1642c2

15 files changed

Lines changed: 362 additions & 32 deletions

File tree

.coverage

52 KB
Binary file not shown.

README.en.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# TechEventRadar
22

3+
[![Tests](https://github.com/Metrohan/eventradar.dev/actions/workflows/test.yml/badge.svg)](https://github.com/Metrohan/eventradar.dev/actions/workflows/test.yml)
4+
[![Deploy](https://github.com/Metrohan/eventradar.dev/actions/workflows/deploy.yml/badge.svg)](https://github.com/Metrohan/eventradar.dev/actions/workflows/deploy.yml)
5+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6+
[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/)
7+
[![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)](docker-compose.yml)
8+
39
One-sentence goal: **Help students and recent graduates discover current technology events in one place.**
410

511
TechEventRadar collects bootcamps, webinars, hackathons, career events, and community meetups from different sources and presents them on a single screen. It helps students answer "what event is happening, when is it, where is it, and how do I apply" without checking each site one by one.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# TechEventRadar
22

3+
[![Tests](https://github.com/Metrohan/eventradar.dev/actions/workflows/test.yml/badge.svg)](https://github.com/Metrohan/eventradar.dev/actions/workflows/test.yml)
4+
[![Deploy](https://github.com/Metrohan/eventradar.dev/actions/workflows/deploy.yml/badge.svg)](https://github.com/Metrohan/eventradar.dev/actions/workflows/deploy.yml)
5+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6+
[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/)
7+
[![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)](docker-compose.yml)
8+
39
[English README](README.en.md)
410

511
Tek cümle amaç: **Öğrencilerin ve yeni mezunların güncel teknoloji etkinliklerini tek yerde kolayca bulması.**

app/api/public.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from fastapi import APIRouter, Depends, HTTPException, Query
22
from sqlalchemy.orm import Session
3+
from sqlalchemy import desc
34
from typing import List, Optional
5+
from datetime import datetime, timezone
46
from ..core.database import get_db
57
from ..services.event_service import EventService
68
from ..services.announcement_service import AnnouncementService
@@ -10,6 +12,8 @@
1012
from ..schemas.announcement import AnnouncementResponse, AnnouncementListResponse
1113
from ..schemas.suggestion import SuggestionCreate, SuggestionResponse
1214
from ..schemas.event_request import EventRequestCreate, EventRequestResponse
15+
from ..models.scraper_log import ScraperLog
16+
from ..models.event import Event
1317

1418
router = APIRouter()
1519

@@ -86,6 +90,45 @@ async def get_event(event_id: int, db: Session = Depends(get_db)):
8690
return event
8791

8892

93+
@router.get("/status")
94+
async def get_status(db: Session = Depends(get_db)):
95+
"""Platform sağlık durumu — scraper kayıtları, aktif etkinlik sayısı."""
96+
try:
97+
active_count = db.query(Event).filter(Event.is_active == True).count()
98+
total_count = db.query(Event).count()
99+
100+
sources = db.query(ScraperLog.source).distinct().all()
101+
scrapers = []
102+
for (source,) in sources:
103+
latest: Optional[ScraperLog] = (
104+
db.query(ScraperLog)
105+
.filter(ScraperLog.source == source)
106+
.order_by(desc(ScraperLog.created_at))
107+
.first()
108+
)
109+
if latest:
110+
scrapers.append({
111+
"source": latest.source,
112+
"status": latest.status,
113+
"events_found": latest.events_found,
114+
"new_events": latest.new_events,
115+
"duration_seconds": round(latest.duration_seconds or 0, 1),
116+
"last_run": latest.created_at.isoformat() if latest.created_at else None,
117+
"error": (latest.error_message[:120] if latest.error_message else None),
118+
})
119+
120+
scrapers.sort(key=lambda s: s["last_run"] or "", reverse=True)
121+
122+
return {
123+
"active_events": active_count,
124+
"total_events": total_count,
125+
"scrapers": scrapers,
126+
"generated_at": datetime.now(timezone.utc).isoformat(),
127+
}
128+
except Exception as e:
129+
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
130+
131+
89132
@router.post("/suggestions", response_model=SuggestionResponse)
90133
async def submit_suggestion(
91134
suggestion: SuggestionCreate, db: Session = Depends(get_db)

app/services/date_extractor.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
from dateparser import parse as dateparser_parse
44
from typing import Optional
55

6+
_MIN_YEAR = 2015
7+
_MAX_YEAR_OFFSET = 4 # now + 4 years
8+
9+
10+
def _in_sane_range(dt: datetime) -> bool:
11+
return _MIN_YEAR <= dt.year <= datetime.now().year + _MAX_YEAR_OFFSET
12+
613

714
def extract_date_from_text(text: str) -> Optional[datetime]:
815
"""
@@ -45,21 +52,27 @@ def parse_event_date(raw: str) -> Optional[datetime]:
4552
if not text:
4653
return None
4754

55+
# Rakam içermeyen metin gerçek tarih olamaz; dateparser'a verme
56+
if not re.search(r"\d", text):
57+
return None
58+
4859
# ISO format: 2026-05-15 veya 2026-05-15 14:30:00
4960
iso_match = re.match(
5061
r"^(\d{4})[-\/](\d{2})[-\/](\d{2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$",
5162
text,
5263
)
5364
if iso_match:
5465
try:
55-
return datetime(
66+
dt = datetime(
5667
int(iso_match.group(1)),
5768
int(iso_match.group(2)),
5869
int(iso_match.group(3)),
5970
int(iso_match.group(4) or 0),
6071
int(iso_match.group(5) or 0),
6172
int(iso_match.group(6) or 0),
6273
)
74+
if _in_sane_range(dt):
75+
return dt
6376
except ValueError:
6477
pass
6578

@@ -70,14 +83,16 @@ def parse_event_date(raw: str) -> Optional[datetime]:
7083
)
7184
if dmy_match:
7285
try:
73-
return datetime(
86+
dt = datetime(
7487
int(dmy_match.group(3)),
7588
int(dmy_match.group(2)),
7689
int(dmy_match.group(1)),
7790
int(dmy_match.group(4) or 0),
7891
int(dmy_match.group(5) or 0),
7992
int(dmy_match.group(6) or 0),
8093
)
94+
if _in_sane_range(dt):
95+
return dt
8196
except ValueError:
8297
pass
8398

@@ -91,9 +106,10 @@ def parse_event_date(raw: str) -> Optional[datetime]:
91106
"DATE_ORDER": "DMY",
92107
"TIMEZONE": "UTC",
93108
"RETURN_AS_TIMEZONE_AWARE": False,
109+
"PREFER_DAY_OF_MONTH": "first",
94110
},
95111
)
96-
if dt:
112+
if dt and _in_sane_range(dt):
97113
return dt
98114
except Exception:
99115
pass
@@ -111,7 +127,9 @@ def parse_event_date(raw: str) -> Optional[datetime]:
111127
"%Y-%m-%d",
112128
):
113129
try:
114-
return datetime.strptime(text, fmt)
130+
dt = datetime.strptime(text, fmt)
131+
if _in_sane_range(dt):
132+
return dt
115133
except ValueError:
116134
continue
117135

app/services/scraper_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,13 @@ def process_scraped_events(events_data: List[Dict], source_name: str) -> str:
187187
all_tags: dict[str, Tag] = {str(t.name): t for t in db.query(Tag).all()}
188188

189189
now = datetime.now()
190-
skipped_irrelevant = 0
190+
seen_urls: set[str] = set()
191191
new_event_data: list[dict] = []
192192
for data in events_data:
193193
url = data.get("url")
194-
if not url:
194+
if not url or url in seen_urls:
195195
continue
196+
seen_urls.add(url)
196197
try:
197198
date_val = normalize_date(data.get("date"))
198199
existing_event = existing_map.get(url)

docker-compose.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,10 @@ services:
6565
dockerfile: Dockerfile
6666
container_name: techeventradar_frontend
6767
ports:
68-
- "127.0.0.1:3000:3000"
69-
volumes:
70-
- ./frontend:/app
71-
- /app/node_modules
72-
environment:
73-
- VITE_API_URL=/api
68+
- "127.0.0.1:3000:80"
7469
depends_on:
7570
- backend
71+
restart: unless-stopped
7672

7773
volumes:
7874
postgres_data:

frontend/Dockerfile

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
FROM node:18-alpine
2-
3-
# Set working directory
4-
WORKDIR /app
5-
6-
# Copy package files
7-
COPY package*.json ./
8-
9-
# Install dependencies
10-
RUN npm install
11-
12-
# Copy source code
13-
COPY . .
14-
15-
# Expose port
16-
EXPOSE 3000
17-
18-
# Start development server
19-
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
20-
1+
FROM node:18-alpine AS builder
2+
3+
WORKDIR /app
4+
5+
COPY package*.json ./
6+
RUN npm ci --omit=dev=false
7+
8+
COPY . .
9+
RUN npm run build
10+
11+
# ── production stage ──────────────────────────────────────────────────────────
12+
FROM nginx:alpine
13+
14+
COPY --from=builder /app/dist /usr/share/nginx/html
15+
COPY nginx.conf /etc/nginx/conf.d/default.conf
16+
17+
EXPOSE 80
18+
19+
CMD ["nginx", "-g", "daemon off;"]

frontend/nginx.conf

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
server {
2+
listen 80;
3+
server_name _;
4+
5+
root /usr/share/nginx/html;
6+
index index.html;
7+
8+
# Proxy API calls to backend
9+
location /api/ {
10+
proxy_pass http://backend:8000/api/;
11+
proxy_set_header Host $host;
12+
proxy_set_header X-Real-IP $remote_addr;
13+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
14+
proxy_set_header X-Forwarded-Proto $scheme;
15+
}
16+
17+
location /sitemap.xml {
18+
proxy_pass http://backend:8000/sitemap.xml;
19+
proxy_set_header Host $host;
20+
proxy_set_header X-Real-IP $remote_addr;
21+
}
22+
23+
# SPA fallback — all unmatched routes serve index.html
24+
location / {
25+
try_files $uri $uri/ /index.html;
26+
}
27+
28+
# Cache static assets aggressively
29+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
30+
expires 1y;
31+
add_header Cache-Control "public, immutable";
32+
}
33+
}

frontend/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const FreeTrainingsPage = lazy(() => import('./pages/FreeTrainingsPage'))
1919
const EventDetailPage = lazy(() => import('./pages/EventDetailPage'))
2020
const BootcampRehberi = lazy(() => import('./pages/BootcampRehberi'))
2121
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
22+
const StatusPage = lazy(() => import('./pages/StatusPage'))
2223
const ServerErrorPage = lazy(() => import('./pages/ServerErrorPage'))
2324
const RedirectPage = lazy(() => import('./pages/RedirectPage'))
2425
import { AuthProvider } from './contexts/AuthContext'
@@ -56,6 +57,7 @@ function App() {
5657
<Route path="/etkinlik-talep" element={<EventRequestPage />} />
5758
<Route path="/oneri-sikayet" element={<SuggestionPage />} />
5859
<Route path="/egitim-kaynaklari" element={<FreeTrainingsPage />} />
60+
<Route path="/status" element={<StatusPage />} />
5961

6062
{/* Admin Routes */}
6163
<Route path="/admin/login" element={<AdminLoginPage />} />

0 commit comments

Comments
 (0)