feat: add boilerplate
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Guess the RAL
|
||||
18
app/config.py
Normal file
18
app/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
admin_password: str = "changeme"
|
||||
secret_key: str = "change-this-secret"
|
||||
database_url: str = "sqlite:///./data/guesstheral.db"
|
||||
linkedin_user_agent: str = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
39
app/database.py
Normal file
39
app/database.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
connect_args = {}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False, "timeout": 30}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=connect_args,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
185
app/game.py
Normal file
185
app/game.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import random
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Answer, GameSession, Job, Player, Round
|
||||
from app.ral import option_payload
|
||||
|
||||
|
||||
WORK_LABELS = {
|
||||
"hybrid": "Hybrid",
|
||||
"remote": "Full remote",
|
||||
"onsite": "In-place only",
|
||||
}
|
||||
|
||||
|
||||
def playable_jobs(db: Session) -> list[Job]:
|
||||
return (
|
||||
db.query(Job)
|
||||
.filter(Job.status.in_(["ready", "needs_salary"]))
|
||||
.filter((Job.ral_exact.isnot(None)) | (Job.ral_min.isnot(None)))
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def start_game(db: Session, session: GameSession) -> Round:
|
||||
jobs = playable_jobs(db)
|
||||
if not jobs:
|
||||
raise ValueError("No playable jobs in the database. Add some from the admin panel.")
|
||||
|
||||
hard = [j for j in jobs if j.difficulty >= 2] or jobs
|
||||
easy = [j for j in jobs if j.difficulty == 1] or jobs
|
||||
random.shuffle(hard)
|
||||
random.shuffle(easy)
|
||||
|
||||
selected: list[Job] = []
|
||||
hi = ei = 0
|
||||
for i in range(session.num_rounds):
|
||||
bonus = (i + 1) % 3 == 0
|
||||
pool = hard if bonus else easy
|
||||
idx_source = hi if bonus else ei
|
||||
job = pool[idx_source % len(pool)]
|
||||
if bonus:
|
||||
hi += 1
|
||||
else:
|
||||
ei += 1
|
||||
selected.append(job)
|
||||
|
||||
for old in list(session.rounds):
|
||||
db.delete(old)
|
||||
db.flush()
|
||||
first = None
|
||||
for i, job in enumerate(selected, start=1):
|
||||
payload = json.loads(option_payload(job))
|
||||
rnd = Round(
|
||||
session_id=session.id,
|
||||
job_id=job.id,
|
||||
round_number=i,
|
||||
is_bonus=(i % 3 == 0),
|
||||
options_json=json.dumps(payload["options"]),
|
||||
correct_option=payload["correct"],
|
||||
status="open" if i == 1 else "pending",
|
||||
)
|
||||
db.add(rnd)
|
||||
if i == 1:
|
||||
first = rnd
|
||||
session.status = "playing"
|
||||
session.current_round = 1
|
||||
db.commit()
|
||||
db.refresh(first)
|
||||
return first
|
||||
|
||||
|
||||
def current_round(db: Session, session: GameSession) -> Round | None:
|
||||
return (
|
||||
db.query(Round)
|
||||
.filter(Round.session_id == session.id, Round.round_number == session.current_round)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def score_answer(rnd: Round, job: Job, choice: str, precise: int | None, work_type: str) -> int:
|
||||
points = 0
|
||||
mc_ok = choice == rnd.correct_option
|
||||
if rnd.is_bonus:
|
||||
if mc_ok:
|
||||
points += 150
|
||||
if precise is not None and job.ral_exact:
|
||||
points += 300 if precise == job.ral_exact else -50
|
||||
if work_type and work_type == job.work_type:
|
||||
points += 50
|
||||
else:
|
||||
if mc_ok:
|
||||
points += 100
|
||||
if precise is not None and job.ral_exact:
|
||||
points += 200 if precise == job.ral_exact else -50
|
||||
return points
|
||||
|
||||
|
||||
def submit_answer(
|
||||
db: Session,
|
||||
player: Player,
|
||||
rnd: Round,
|
||||
choice: str,
|
||||
precise: int | None,
|
||||
work_type: str,
|
||||
) -> Answer:
|
||||
existing = (
|
||||
db.query(Answer)
|
||||
.filter(Answer.round_id == rnd.id, Answer.player_id == player.id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
if rnd.status != "open":
|
||||
raise ValueError("This round is closed")
|
||||
job = rnd.job
|
||||
points = score_answer(rnd, job, choice, precise, work_type)
|
||||
answer = Answer(
|
||||
round_id=rnd.id,
|
||||
player_id=player.id,
|
||||
choice=choice,
|
||||
precise_guess=precise,
|
||||
work_type_guess=work_type or "",
|
||||
points=points,
|
||||
)
|
||||
db.add(answer)
|
||||
db.commit()
|
||||
db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
def maybe_close_round(db: Session, session: GameSession, rnd: Round) -> bool:
|
||||
db.refresh(rnd)
|
||||
active = [p for p in session.players if p.connected]
|
||||
if not active:
|
||||
return False
|
||||
answers = db.query(Answer).filter(Answer.round_id == rnd.id).all()
|
||||
answered_ids = {a.player_id for a in answers}
|
||||
if not all(p.id in answered_ids for p in active):
|
||||
return False
|
||||
rnd.status = "revealed"
|
||||
for answer in answers:
|
||||
answer.player.score += answer.points
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def advance(db: Session, session: GameSession) -> Round | None:
|
||||
nxt = (
|
||||
db.query(Round)
|
||||
.filter(Round.session_id == session.id, Round.round_number == session.current_round + 1)
|
||||
.first()
|
||||
)
|
||||
if not nxt:
|
||||
session.status = "finished"
|
||||
db.commit()
|
||||
return None
|
||||
nxt.status = "open"
|
||||
session.current_round += 1
|
||||
db.commit()
|
||||
db.refresh(nxt)
|
||||
return nxt
|
||||
|
||||
|
||||
def public_job(session: GameSession, job: Job, bonus: bool) -> dict:
|
||||
description = job.description_clean
|
||||
if bonus:
|
||||
words = description.split()
|
||||
description = " ".join(words[: max(40, len(words) // 2)])
|
||||
if len(words) > 40:
|
||||
description += " ..."
|
||||
data = {
|
||||
"title": job.title,
|
||||
"description": description,
|
||||
"has_exact": job.ral_exact is not None,
|
||||
"location": job.location,
|
||||
}
|
||||
if not session.hide_company:
|
||||
data["company"] = job.company
|
||||
if not session.hide_subject:
|
||||
data["subject"] = job.subject
|
||||
if session.extra_guesses:
|
||||
data["extra"] = job.extra_info
|
||||
return data
|
||||
141
app/linkedin.py
Normal file
141
app/linkedin.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Job
|
||||
from app.ral import detect_subject, detect_work_type, extract_salary, hide_salary
|
||||
|
||||
|
||||
def _text(el) -> str:
|
||||
return el.get_text(" ", strip=True) if el else ""
|
||||
|
||||
|
||||
def _from_ld_json(data: dict) -> dict:
|
||||
title = data.get("title") or ""
|
||||
description = BeautifulSoup(data.get("description") or "", "lxml").get_text(" ", strip=True)
|
||||
org = data.get("hiringOrganization") or {}
|
||||
company = org.get("name") if isinstance(org, dict) else ""
|
||||
loc = data.get("jobLocation") or {}
|
||||
if isinstance(loc, list) and loc:
|
||||
loc = loc[0]
|
||||
location = ""
|
||||
if isinstance(loc, dict):
|
||||
address = loc.get("address") or {}
|
||||
if isinstance(address, dict):
|
||||
location = address.get("addressLocality") or address.get("addressRegion") or ""
|
||||
extracted = {"ral_min": None, "ral_max": None, "ral_exact": None}
|
||||
salary = data.get("baseSalary") or {}
|
||||
value = salary.get("value") if isinstance(salary, dict) else {}
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
if value.get("value"):
|
||||
extracted["ral_exact"] = int(float(str(value["value"]).replace(",", "")))
|
||||
if value.get("minValue"):
|
||||
extracted["ral_min"] = int(float(str(value["minValue"]).replace(",", "")))
|
||||
if value.get("maxValue"):
|
||||
extracted["ral_max"] = int(float(str(value["maxValue"]).replace(",", "")))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"company": company or "",
|
||||
"location": location or "",
|
||||
**extracted,
|
||||
}
|
||||
|
||||
|
||||
def parse_job_html(html: str, url: str) -> dict:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
title = _text(soup.select_one("h1")) or _text(soup.select_one(".top-card-layout__title"))
|
||||
company = _text(soup.select_one(".topcard__org-name-link")) or _text(
|
||||
soup.select_one(".top-card-layout__card .topcard__flavor")
|
||||
)
|
||||
location = _text(soup.select_one(".topcard__flavor--bullet"))
|
||||
description = _text(soup.select_one(".show-more-less-html__markup")) or _text(
|
||||
soup.select_one(".description__text")
|
||||
)
|
||||
extracted = {"ral_min": None, "ral_max": None, "ral_exact": None}
|
||||
|
||||
for script in soup.select('script[type="application/ld+json"]'):
|
||||
try:
|
||||
data = json.loads(script.text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(data, list):
|
||||
data = next(
|
||||
(item for item in data if isinstance(item, dict) and item.get("@type") == "JobPosting"),
|
||||
{},
|
||||
)
|
||||
if isinstance(data, dict) and data.get("@type") == "JobPosting":
|
||||
parsed = _from_ld_json(data)
|
||||
title = title or parsed["title"]
|
||||
description = description or parsed["description"]
|
||||
company = company or parsed["company"]
|
||||
location = location or parsed["location"]
|
||||
extracted = {k: parsed[k] for k in ("ral_min", "ral_max", "ral_exact")}
|
||||
break
|
||||
|
||||
if not title:
|
||||
og = soup.select_one('meta[property="og:title"]')
|
||||
title = og["content"] if og and og.get("content") else "LinkedIn job"
|
||||
|
||||
blob = f"{title} {company} {description}"
|
||||
if not any(extracted.values()):
|
||||
extracted = extract_salary(blob)
|
||||
|
||||
extra_bits = []
|
||||
if location:
|
||||
extra_bits.append(f"Location: {location}")
|
||||
extra_bits.extend(_text(el) for el in soup.select(".description__job-criteria-item") if _text(el))
|
||||
|
||||
has_salary = bool(extracted.get("ral_exact") or extracted.get("ral_min"))
|
||||
spread = 0
|
||||
if extracted.get("ral_max") and extracted.get("ral_min"):
|
||||
spread = extracted["ral_max"] - extracted["ral_min"]
|
||||
|
||||
fields = {c.name for c in Job.__table__.columns}
|
||||
payload = {
|
||||
"linkedin_url": url,
|
||||
"title": (title or "LinkedIn job")[:300],
|
||||
"company": (company or "Unknown company")[:200],
|
||||
"location": (location or "")[:200],
|
||||
"description": description or "",
|
||||
"description_clean": hide_salary(description or ""),
|
||||
"extra_info": " | ".join(extra_bits)[:500],
|
||||
"subject": detect_subject(title or "", description or ""),
|
||||
"work_type": detect_work_type(blob),
|
||||
"difficulty": 2 if spread > 15000 else 1,
|
||||
"source": "linkedin",
|
||||
"status": "ready" if has_salary else "needs_salary",
|
||||
"fetch_error": "" if has_salary else "Salary not found; edit in admin",
|
||||
**extracted,
|
||||
}
|
||||
return {k: v for k, v in payload.items() if k in fields}
|
||||
|
||||
|
||||
async def fetch_linkedin_job(url: str) -> dict:
|
||||
if "linkedin.com" not in url:
|
||||
raise ValueError("Only LinkedIn job URLs are supported")
|
||||
headers = {
|
||||
"User-Agent": settings.linkedin_user_agent,
|
||||
"Accept-Language": "en-US,en;q=0.9,it;q=0.8",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=25.0, headers=headers) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
if "authwall" in str(response.url):
|
||||
raise RuntimeError("LinkedIn showed a login wall for this URL")
|
||||
html = response.text
|
||||
if "/jobs/view/" in url and "JobPosting" not in html:
|
||||
guest = url.split("?")[0]
|
||||
response = await client.get(guest)
|
||||
html = response.text
|
||||
parsed = parse_job_html(html, url)
|
||||
if not parsed.get("description") and not parsed.get("title"):
|
||||
raise RuntimeError("Could not parse job posting from LinkedIn page")
|
||||
return parsed
|
||||
385
app/main.py
Normal file
385
app/main.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.game import (
|
||||
WORK_LABELS,
|
||||
advance,
|
||||
current_round,
|
||||
maybe_close_round,
|
||||
playable_jobs,
|
||||
public_job,
|
||||
start_game,
|
||||
submit_answer,
|
||||
)
|
||||
from app.models import FetchTask, GameSession, Job, Player, Round
|
||||
from app.ral import hide_salary
|
||||
from app.realtime import hub
|
||||
from app.seed import seed_jobs
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
app = FastAPI(title="Guess the RAL")
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
||||
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
Path("data").mkdir(exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
seed_jobs(db)
|
||||
db.close()
|
||||
|
||||
|
||||
def render(request: Request, name: str, **ctx):
|
||||
ctx["request"] = request
|
||||
return templates.TemplateResponse(name, ctx)
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
if not request.session.get("admin"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def snapshot(db: Session, session: GameSession) -> dict:
|
||||
rnd = current_round(db, session) if session.status != "lobby" else None
|
||||
players = [
|
||||
{"id": p.id, "name": p.name, "score": p.score, "connected": p.connected, "host": p.is_host}
|
||||
for p in sorted(session.players, key=lambda x: (-x.score, x.name.lower()))
|
||||
]
|
||||
payload = {
|
||||
"type": "state",
|
||||
"session": {
|
||||
"id": session.id,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
"num_rounds": session.num_rounds,
|
||||
"max_players": session.max_players,
|
||||
"extra_guesses": session.extra_guesses,
|
||||
"hide_company": session.hide_company,
|
||||
"hide_subject": session.hide_subject,
|
||||
},
|
||||
"players": players,
|
||||
}
|
||||
if rnd:
|
||||
answered = {a.player_id for a in rnd.answers}
|
||||
payload["round"] = {
|
||||
"number": rnd.round_number,
|
||||
"bonus": rnd.is_bonus,
|
||||
"status": rnd.status,
|
||||
"options": json.loads(rnd.options_json),
|
||||
"job": public_job(session, rnd.job, rnd.is_bonus),
|
||||
"answered_ids": list(answered),
|
||||
"answered": [p.name for p in session.players if p.id in answered],
|
||||
"waiting": [p.name for p in session.players if p.connected and p.id not in answered],
|
||||
"work_types": WORK_LABELS,
|
||||
}
|
||||
if rnd.status == "revealed":
|
||||
payload["round"]["correct"] = rnd.correct_option
|
||||
payload["round"]["ral_exact"] = rnd.job.ral_exact
|
||||
payload["round"]["ral_min"] = rnd.job.ral_min
|
||||
payload["round"]["ral_max"] = rnd.job.ral_max
|
||||
payload["round"]["work_type"] = rnd.job.work_type
|
||||
payload["round"]["company"] = rnd.job.company
|
||||
payload["round"]["answers"] = [
|
||||
{
|
||||
"name": a.player.name,
|
||||
"choice": a.choice,
|
||||
"precise": a.precise_guess,
|
||||
"work_type": a.work_type_guess,
|
||||
"points": a.points,
|
||||
"correct": a.choice == rnd.correct_option,
|
||||
}
|
||||
for a in rnd.answers
|
||||
]
|
||||
if session.status == "finished":
|
||||
payload["winner"] = players[0]["name"] if players else ""
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request):
|
||||
return render(request, "index.html")
|
||||
|
||||
|
||||
@app.post("/create")
|
||||
def create_session(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
max_players: int = Form(8),
|
||||
num_rounds: int = Form(8),
|
||||
extra_guesses: str | None = Form(None),
|
||||
hide_company: str | None = Form(None),
|
||||
hide_subject: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
name = name.strip()[:24] or "Host"
|
||||
session = GameSession(
|
||||
max_players=max(2, min(12, max_players)),
|
||||
num_rounds=max(4, min(32, num_rounds)),
|
||||
extra_guesses=bool(extra_guesses),
|
||||
hide_company=bool(hide_company),
|
||||
hide_subject=bool(hide_subject),
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
player = Player(session_id=session.id, name=name, is_host=True)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
request.session["player_token"] = player.token
|
||||
request.session["player_name"] = player.name
|
||||
return RedirectResponse(f"/play/{session.id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/join/{session_id}", response_class=HTMLResponse)
|
||||
def join_page(request: Request, session_id: str, db: Session = Depends(get_db)):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
return render(request, "join.html", session=session)
|
||||
|
||||
|
||||
@app.post("/join/{session_id}")
|
||||
def join_session(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
name: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
name = name.strip()[:24] or "Player"
|
||||
existing = next((p for p in session.players if p.name.lower() == name.lower()), None)
|
||||
if existing:
|
||||
player = existing
|
||||
player.connected = True
|
||||
else:
|
||||
if session.status != "lobby":
|
||||
return render(request, "join.html", session=session, error="Match already started")
|
||||
connected = [p for p in session.players if p.connected]
|
||||
if len(connected) >= session.max_players:
|
||||
return render(request, "join.html", session=session, error="Lobby is full")
|
||||
player = Player(session_id=session.id, name=name)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
request.session["player_token"] = player.token
|
||||
request.session["player_name"] = player.name
|
||||
return RedirectResponse(f"/play/{session.id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/play/{session_id}", response_class=HTMLResponse)
|
||||
def play(request: Request, session_id: str, db: Session = Depends(get_db)):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
token = request.session.get("player_token")
|
||||
player = next((p for p in session.players if p.token == token), None)
|
||||
if not player:
|
||||
return RedirectResponse(f"/join/{session_id}", status_code=303)
|
||||
return render(request, "play.html", session=session, player=player)
|
||||
|
||||
|
||||
@app.post("/play/{session_id}/settings")
|
||||
def update_settings(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
max_players: int = Form(8),
|
||||
num_rounds: int = Form(8),
|
||||
extra_guesses: str | None = Form(None),
|
||||
hide_company: str | None = Form(None),
|
||||
hide_subject: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session = db.get(GameSession, session_id)
|
||||
player = next((p for p in session.players if p.token == request.session.get("player_token")), None)
|
||||
if not session or not player or not player.is_host or session.status != "lobby":
|
||||
return RedirectResponse(f"/play/{session_id}", status_code=303)
|
||||
session.max_players = max(2, min(12, max_players))
|
||||
session.num_rounds = max(4, min(32, num_rounds))
|
||||
session.extra_guesses = bool(extra_guesses)
|
||||
session.hide_company = bool(hide_company)
|
||||
session.hide_subject = bool(hide_subject)
|
||||
db.commit()
|
||||
return RedirectResponse(f"/play/{session_id}", status_code=303)
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def ws_play(websocket: WebSocket, session_id: str):
|
||||
token = websocket.query_params.get("token", "")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = db.get(GameSession, session_id)
|
||||
player = next((p for p in (session.players if session else []) if p.token == token), None)
|
||||
if not session or not player:
|
||||
await websocket.close()
|
||||
return
|
||||
player.connected = True
|
||||
db.commit()
|
||||
await hub.join(session_id, websocket)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
db.expire_all()
|
||||
session = db.get(GameSession, session_id)
|
||||
player = db.get(Player, player.id)
|
||||
action = msg.get("action")
|
||||
if action == "start" and player.is_host and session.status == "lobby":
|
||||
try:
|
||||
start_game(db, session)
|
||||
except ValueError as exc:
|
||||
await websocket.send_text(json.dumps({"type": "error", "message": str(exc)}))
|
||||
elif action == "answer" and session.status == "playing":
|
||||
rnd = current_round(db, session)
|
||||
if rnd and rnd.status == "open":
|
||||
precise = msg.get("precise")
|
||||
precise_val = int(precise) if precise not in (None, "", False) else None
|
||||
try:
|
||||
submit_answer(
|
||||
db,
|
||||
player,
|
||||
rnd,
|
||||
str(msg.get("choice") or ""),
|
||||
precise_val,
|
||||
str(msg.get("work_type") or ""),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
db.refresh(rnd)
|
||||
db.refresh(session)
|
||||
if maybe_close_round(db, session, rnd):
|
||||
db.refresh(session)
|
||||
elif action == "next" and player.is_host:
|
||||
rnd = current_round(db, session)
|
||||
if rnd and rnd.status == "revealed":
|
||||
advance(db, session)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.leave(session_id, websocket)
|
||||
try:
|
||||
player = next((p for p in db.get(GameSession, session_id).players if p.token == token), None)
|
||||
if player:
|
||||
player.connected = False
|
||||
db.commit()
|
||||
session = db.get(GameSession, session_id)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
except Exception:
|
||||
pass
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin(request: Request, db: Session = Depends(get_db)):
|
||||
if not require_admin(request):
|
||||
return render(request, "admin_login.html")
|
||||
jobs = db.query(Job).order_by(Job.id.desc()).limit(80).all()
|
||||
tasks = db.query(FetchTask).order_by(FetchTask.id.desc()).limit(40).all()
|
||||
queued = db.query(FetchTask).filter(FetchTask.status.in_(["queued", "running"])).count()
|
||||
return render(
|
||||
request,
|
||||
"admin.html",
|
||||
jobs=jobs,
|
||||
tasks=tasks,
|
||||
queued=queued,
|
||||
playable=len(playable_jobs(db)),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/login")
|
||||
def admin_login(request: Request, password: str = Form(...)):
|
||||
if password != settings.admin_password:
|
||||
return render(request, "admin_login.html", error="Wrong password")
|
||||
request.session["admin"] = True
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/logout")
|
||||
def admin_logout(request: Request):
|
||||
request.session.pop("admin", None)
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/fetch")
|
||||
def admin_fetch(request: Request, url: str = Form(...), db: Session = Depends(get_db)):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
db.add(FetchTask(url=url.strip(), status="queued"))
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/job")
|
||||
def admin_add_job(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
company: str = Form(""),
|
||||
subject: str = Form("other"),
|
||||
location: str = Form(""),
|
||||
work_type: str = Form("hybrid"),
|
||||
ral_exact: str = Form(""),
|
||||
ral_min: str = Form(""),
|
||||
ral_max: str = Form(""),
|
||||
extra_info: str = Form(""),
|
||||
description: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
def num(v):
|
||||
v = (v or "").replace(".", "").replace(" ", "")
|
||||
return int(v) if v.isdigit() else None
|
||||
|
||||
exact, low, high = num(ral_exact), num(ral_min), num(ral_max)
|
||||
db.add(
|
||||
Job(
|
||||
title=title,
|
||||
company=company,
|
||||
subject=subject,
|
||||
location=location,
|
||||
work_type=work_type,
|
||||
ral_exact=exact,
|
||||
ral_min=low,
|
||||
ral_max=high,
|
||||
extra_info=extra_info,
|
||||
description=description,
|
||||
description_clean=hide_salary(description),
|
||||
status="ready" if (exact or low) else "needs_salary",
|
||||
source="manual",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/job/{job_id}/status")
|
||||
def admin_job_status(
|
||||
request: Request,
|
||||
job_id: int,
|
||||
status: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
job = db.get(Job, job_id)
|
||||
if job and status in {"ready", "needs_salary", "hidden"}:
|
||||
job.status = status
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
114
app/models.py
Normal file
114
app/models.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
linkedin_url: Mapped[str] = mapped_column(String(500), default="")
|
||||
title: Mapped[str] = mapped_column(String(300))
|
||||
company: Mapped[str] = mapped_column(String(200), default="")
|
||||
subject: Mapped[str] = mapped_column(String(80), default="other")
|
||||
location: Mapped[str] = mapped_column(String(200), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
description_clean: Mapped[str] = mapped_column(Text, default="")
|
||||
extra_info: Mapped[str] = mapped_column(Text, default="")
|
||||
ral_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ral_max: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ral_exact: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
work_type: Mapped[str] = mapped_column(String(20), default="hybrid")
|
||||
difficulty: Mapped[int] = mapped_column(Integer, default=1)
|
||||
status: Mapped[str] = mapped_column(String(20), default="ready")
|
||||
fetch_error: Mapped[str] = mapped_column(Text, default="")
|
||||
source: Mapped[str] = mapped_column(String(40), default="seed")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class FetchTask(Base):
|
||||
__tablename__ = "fetch_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
url: Mapped[str] = mapped_column(String(500))
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
job_id: Mapped[int | None] = mapped_column(ForeignKey("jobs.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
job: Mapped[Job | None] = relationship()
|
||||
|
||||
|
||||
class GameSession(Base):
|
||||
__tablename__ = "game_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
host_token: Mapped[str] = mapped_column(String(36), default=new_uuid)
|
||||
max_players: Mapped[int] = mapped_column(Integer, default=8)
|
||||
num_rounds: Mapped[int] = mapped_column(Integer, default=8)
|
||||
extra_guesses: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
hide_company: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
hide_subject: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="lobby")
|
||||
current_round: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
players: Mapped[list["Player"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
rounds: Mapped[list["Round"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Player(Base):
|
||||
__tablename__ = "players"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("game_sessions.id"))
|
||||
name: Mapped[str] = mapped_column(String(40))
|
||||
score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
connected: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_host: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
token: Mapped[str] = mapped_column(String(36), default=new_uuid)
|
||||
|
||||
session: Mapped[GameSession] = relationship(back_populates="players")
|
||||
answers: Mapped[list["Answer"]] = relationship(back_populates="player", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Round(Base):
|
||||
__tablename__ = "rounds"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(ForeignKey("game_sessions.id"))
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey("jobs.id"))
|
||||
round_number: Mapped[int] = mapped_column(Integer)
|
||||
is_bonus: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
options_json: Mapped[str] = mapped_column(Text)
|
||||
correct_option: Mapped[str] = mapped_column(String(80))
|
||||
status: Mapped[str] = mapped_column(String(20), default="open")
|
||||
|
||||
session: Mapped[GameSession] = relationship(back_populates="rounds")
|
||||
job: Mapped[Job] = relationship()
|
||||
answers: Mapped[list["Answer"]] = relationship(back_populates="round", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Answer(Base):
|
||||
__tablename__ = "answers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"))
|
||||
player_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
|
||||
choice: Mapped[str] = mapped_column(String(80))
|
||||
precise_guess: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
work_type_guess: Mapped[str] = mapped_column(String(20), default="")
|
||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
round: Mapped[Round] = relationship(back_populates="answers")
|
||||
player: Mapped[Player] = relationship(back_populates="answers")
|
||||
189
app/ral.py
Normal file
189
app/ral.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SALARY_PATTERNS = [
|
||||
r"ral\s*(?:lorda|annua|annuale)?\s*[:\-]?\s*",
|
||||
r"retribuzione\s*(?:annua\s*)?lorda\s*[:\-]?\s*",
|
||||
r"stipendio\s*(?:annuo|lordo)?\s*[:\-]?\s*",
|
||||
r"compensation\s*[:\-]?\s*",
|
||||
r"salary\s*(?:range)?\s*[:\-]?\s*",
|
||||
r"ctc\s*[:\-]?\s*",
|
||||
r"package\s*[:\-]?\s*",
|
||||
]
|
||||
|
||||
NUMBER = r"(?:€|eur|euro)?\s*(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*(?:€|eur|k|mila)?"
|
||||
RANGE = (
|
||||
r"(?:tra|from|between)?\s*"
|
||||
+ NUMBER
|
||||
+ r"\s*(?:-|–|—|/|e|and|to|a)\s*"
|
||||
+ NUMBER
|
||||
)
|
||||
|
||||
BANDS = [
|
||||
(0, 25000, "Less than 25k"),
|
||||
(25000, 30000, "Between 25k and 30k"),
|
||||
(30000, 35000, "Between 30k and 35k"),
|
||||
(35000, 40000, "Between 35k and 40k"),
|
||||
(40000, 45000, "Between 40k and 45k"),
|
||||
(45000, 50000, "Between 45k and 50k"),
|
||||
(50000, 55000, "Between 50k and 55k"),
|
||||
(55000, 60000, "Between 55k and 60k"),
|
||||
(60000, 70000, "Between 60k and 70k"),
|
||||
(70000, 80000, "Between 70k and 80k"),
|
||||
(80000, 90000, "Between 80k and 90k"),
|
||||
(90000, 100000, "Between 90k and 100k"),
|
||||
(100000, 120000, "Between 100k and 120k"),
|
||||
(120000, 150000, "Between 120k and 150k"),
|
||||
(150000, 10**9, "Above 150k"),
|
||||
]
|
||||
|
||||
|
||||
def parse_amount(raw: str) -> int:
|
||||
compact = raw.lower().replace(" ", "").replace("€", "").replace("eur", "")
|
||||
multiplier = 1000 if compact.endswith("k") or compact.endswith("mila") else 1
|
||||
compact = compact.replace("k", "").replace("mila", "").replace(".", "")
|
||||
digits = re.sub(r"[^\d]", "", compact)
|
||||
if not digits:
|
||||
return 0
|
||||
value = int(digits)
|
||||
if multiplier == 1000 and value < 1000:
|
||||
value *= 1000
|
||||
if 1000 <= value < 10000 and not raw.lower().endswith(("k", "mila")):
|
||||
# 35.000 style already handled; 35000 stays
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def extract_salary(text: str) -> dict[str, int | None]:
|
||||
blob = text.lower().replace("\xa0", " ")
|
||||
ral_min = ral_max = ral_exact = None
|
||||
|
||||
for match in re.finditer(RANGE, blob, flags=re.I):
|
||||
a, b = parse_amount(match.group(1)), parse_amount(match.group(2))
|
||||
if 15000 <= a <= 400000 and 15000 <= b <= 400000:
|
||||
ral_min, ral_max = min(a, b), max(a, b)
|
||||
break
|
||||
|
||||
if ral_min is None:
|
||||
labeled = re.search(
|
||||
r"(?:ral|retribuzione|salary|stipendio|package)[^\n]{0,40}" + NUMBER,
|
||||
blob,
|
||||
flags=re.I,
|
||||
)
|
||||
if labeled:
|
||||
value = parse_amount(labeled.group(1))
|
||||
if 15000 <= value <= 400000:
|
||||
ral_exact = value
|
||||
|
||||
if ral_min is None and ral_exact is None:
|
||||
above = re.search(r"(?:above|oltre|maggiore di|>)\s*" + NUMBER, blob, flags=re.I)
|
||||
if above:
|
||||
value = parse_amount(above.group(1))
|
||||
if 15000 <= value <= 400000:
|
||||
ral_min = value
|
||||
ral_max = value + 20000
|
||||
less = re.search(r"(?:less than|fino a|inferiore a|<)\s*" + NUMBER, blob, flags=re.I)
|
||||
if less and ral_min is None:
|
||||
value = parse_amount(less.group(1))
|
||||
if 15000 <= value <= 400000:
|
||||
ral_min = max(15000, value - 15000)
|
||||
ral_max = value
|
||||
|
||||
return {"ral_min": ral_min, "ral_max": ral_max, "ral_exact": ral_exact}
|
||||
|
||||
|
||||
def hide_salary(text: str) -> str:
|
||||
cleaned = text
|
||||
replacements = [
|
||||
RANGE,
|
||||
r"(?:ral|retribuzione\s*annua\s*lorda|stipendio|salary|compensation|ctc)\s*[:\-]?\s*(?:€|eur|euro)?\s*\d[\d.\sKkmila€euro\-–toaeand/]{1,48}",
|
||||
r"(?:€|eur)\s*\d{2,3}(?:[.\s]\d{3})+(?:\s*(?:-|–|to|a)\s*(?:€|eur)?\s*\d{2,3}(?:[.\s]\d{3})+)?",
|
||||
r"\b\d{2,3}\s*k(?:\s*(?:-|–|to|a)\s*\d{2,3}\s*k)?\b",
|
||||
r"\b\d{5,6}\s*(?:€|eur|euro)?\b",
|
||||
]
|
||||
for pattern in replacements:
|
||||
cleaned = re.sub(pattern, "[RAL HIDDEN]", cleaned, flags=re.I)
|
||||
cleaned = re.sub(r"(\[RAL HIDDEN\]\s*){2,}", "[RAL HIDDEN] ", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def midpoint(job: Any) -> int:
|
||||
if job.ral_exact:
|
||||
return job.ral_exact
|
||||
if job.ral_min and job.ral_max:
|
||||
return (job.ral_min + job.ral_max) // 2
|
||||
return job.ral_min or job.ral_max or 40000
|
||||
|
||||
|
||||
def correct_band(value: int) -> tuple[int, int, str]:
|
||||
for low, high, label in BANDS:
|
||||
if low <= value < high:
|
||||
return low, high, label
|
||||
return BANDS[-1]
|
||||
|
||||
|
||||
def generate_options(job: Any) -> tuple[list[dict], str]:
|
||||
value = midpoint(job)
|
||||
idx = next(i for i, (low, high, _) in enumerate(BANDS) if low <= value < high)
|
||||
|
||||
start = max(0, idx - 2)
|
||||
end = min(len(BANDS), start + 4)
|
||||
start = max(0, end - 4)
|
||||
chosen = BANDS[start:end]
|
||||
if len(chosen) < 4:
|
||||
chosen = BANDS[:4]
|
||||
|
||||
options = []
|
||||
correct = ""
|
||||
for low, high, label in chosen:
|
||||
if high >= 10**9:
|
||||
key = f"above_{low}"
|
||||
text = f"Above {low // 1000}k"
|
||||
elif low == 0:
|
||||
key = f"less_{high}"
|
||||
text = f"Less than {high // 1000}k"
|
||||
else:
|
||||
key = f"between_{low}_{high}"
|
||||
text = f"Between {low // 1000}k and {high // 1000}k"
|
||||
is_correct = low <= value < high
|
||||
if is_correct:
|
||||
correct = key
|
||||
options.append({"key": key, "label": text})
|
||||
|
||||
random.shuffle(options)
|
||||
return options, correct
|
||||
|
||||
|
||||
def option_payload(job: Any) -> str:
|
||||
options, correct = generate_options(job)
|
||||
return json.dumps({"options": options, "correct": correct})
|
||||
|
||||
|
||||
def detect_work_type(text: str) -> str:
|
||||
blob = text.lower()
|
||||
if any(w in blob for w in ["full remote", "fully remote", "100% remote", "full-remote", "da remoto"]):
|
||||
return "remote"
|
||||
if any(w in blob for w in ["hybrid", "ibrido", "smart working"]):
|
||||
return "hybrid"
|
||||
if any(w in blob for w in ["on-site", "onsite", "in sede", "in office", "presenza"]):
|
||||
return "onsite"
|
||||
return "hybrid"
|
||||
|
||||
|
||||
def detect_subject(title: str, description: str) -> str:
|
||||
blob = f"{title} {description}".lower()
|
||||
mapping = {
|
||||
"technology": ["developer", "engineer", "software", "data", "devops", "it ", "python", "java", "cloud"],
|
||||
"financial": ["bank", "finance", "fintech", "accounting", "auditor", "investment"],
|
||||
"business": ["sales", "account manager", "business", "marketing", "product manager"],
|
||||
"healthcare": ["nurse", "medical", "pharma", "clinic", "health"],
|
||||
"legal": ["lawyer", "legal", "compliance", "avvocato"],
|
||||
"hr": ["recruiter", "human resources", "hr ", "people"],
|
||||
"design": ["designer", "ux", "ui", "graphic"],
|
||||
}
|
||||
for subject, words in mapping.items():
|
||||
if any(w in blob for w in words):
|
||||
return subject
|
||||
return "other"
|
||||
31
app/realtime.py
Normal file
31
app/realtime.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self) -> None:
|
||||
self.rooms: dict[str, list[WebSocket]] = defaultdict(list)
|
||||
|
||||
async def join(self, session_id: str, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self.rooms[session_id].append(ws)
|
||||
|
||||
def leave(self, session_id: str, ws: WebSocket) -> None:
|
||||
conns = self.rooms.get(session_id, [])
|
||||
if ws in conns:
|
||||
conns.remove(ws)
|
||||
|
||||
async def broadcast(self, session_id: str, payload: dict) -> None:
|
||||
dead = []
|
||||
for ws in list(self.rooms.get(session_id, [])):
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.leave(session_id, ws)
|
||||
|
||||
|
||||
hub = Hub()
|
||||
45
app/seed.py
Normal file
45
app/seed.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Job
|
||||
from app.ral import hide_salary
|
||||
|
||||
|
||||
def seed_jobs(db: Session) -> int:
|
||||
path = Path(__file__).resolve().parent.parent / "seed_jobs.json"
|
||||
if not path.exists():
|
||||
return 0
|
||||
jobs = json.loads(path.read_text(encoding="utf-8"))
|
||||
created = 0
|
||||
for item in jobs:
|
||||
exists = (
|
||||
db.query(Job)
|
||||
.filter(Job.title == item["title"], Job.company == item["company"])
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
description = item["description"]
|
||||
db.add(
|
||||
Job(
|
||||
title=item["title"],
|
||||
company=item["company"],
|
||||
subject=item.get("subject", "other"),
|
||||
location=item.get("location", ""),
|
||||
description=description,
|
||||
description_clean=hide_salary(description),
|
||||
extra_info=item.get("extra_info", ""),
|
||||
ral_min=item.get("ral_min"),
|
||||
ral_max=item.get("ral_max"),
|
||||
ral_exact=item.get("ral_exact"),
|
||||
work_type=item.get("work_type", "hybrid"),
|
||||
difficulty=item.get("difficulty", 1),
|
||||
status="ready",
|
||||
source="seed",
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
db.commit()
|
||||
return created
|
||||
183
app/static/css/style.css
Normal file
183
app/static/css/style.css
Normal file
@@ -0,0 +1,183 @@
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
--panel: #1a1433;
|
||||
--ink: #f4e8c1;
|
||||
--accent: #7dff6a;
|
||||
--pink: #ff5d8f;
|
||||
--cyan: #5ce1ff;
|
||||
--gold: #ffd35c;
|
||||
--shadow: #05060c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
background: radial-gradient(circle at 20% 0%, #2a1b5c 0%, var(--bg) 45%);
|
||||
color: var(--ink);
|
||||
font-family: "VT323", monospace;
|
||||
font-size: 22px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0.15) 0px,
|
||||
rgba(0,0,0,0.15) 1px,
|
||||
transparent 2px,
|
||||
transparent 4px
|
||||
);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 4px solid var(--accent);
|
||||
}
|
||||
.logo {
|
||||
font-family: "Press Start 2P", monospace;
|
||||
font-size: 14px;
|
||||
color: var(--gold);
|
||||
text-decoration: none;
|
||||
text-shadow: 3px 3px 0 #000;
|
||||
}
|
||||
nav a { color: var(--cyan); text-decoration: none; font-family: "Press Start 2P", monospace; font-size: 10px; }
|
||||
|
||||
main { max-width: 1100px; margin: 24px auto; padding: 0 16px 48px; }
|
||||
|
||||
.pixel-panel {
|
||||
background: var(--panel);
|
||||
border: 4px solid var(--ink);
|
||||
box-shadow: 8px 8px 0 var(--shadow);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { font-family: "Press Start 2P", monospace; font-size: 14px; color: var(--gold); }
|
||||
.tiny { font-size: 16px; opacity: 0.8; word-break: break-all; }
|
||||
.hint { text-align: center; opacity: 0.7; }
|
||||
|
||||
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; font-family: "Press Start 2P", monospace; font-size: 10px; }
|
||||
input, select, textarea {
|
||||
font-family: "VT323", monospace;
|
||||
font-size: 22px;
|
||||
background: #0d0820;
|
||||
color: var(--ink);
|
||||
border: 3px solid var(--cyan);
|
||||
padding: 8px;
|
||||
}
|
||||
.check { flex-direction: row; align-items: center; gap: 10px; }
|
||||
.check input { width: auto; }
|
||||
|
||||
.btn {
|
||||
font-family: "Press Start 2P", monospace;
|
||||
font-size: 12px;
|
||||
background: var(--accent);
|
||||
color: #041204;
|
||||
border: 4px solid #000;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 4px 4px 0 #000;
|
||||
}
|
||||
.btn:hover { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
|
||||
.btn-ghost { background: var(--cyan); }
|
||||
.btn-small, .btn-tiny { font-size: 9px; padding: 8px; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.alert { background: var(--pink); color: #000; padding: 8px; border: 3px solid #000; }
|
||||
|
||||
.play-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; }
|
||||
.board ol { padding-left: 22px; }
|
||||
.board li { margin: 6px 0; }
|
||||
.board li.host::after { content: " ★"; color: var(--gold); }
|
||||
.board li.off { opacity: 0.4; }
|
||||
|
||||
.stage .job-title { font-family: "Press Start 2P", monospace; font-size: 13px; line-height: 1.6; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.chip { background: #000; border: 2px solid var(--cyan); padding: 4px 8px; font-size: 16px; }
|
||||
.chip.bonus { border-color: var(--gold); color: var(--gold); }
|
||||
.description { background: #0d0820; border: 3px dashed var(--pink); padding: 12px; line-height: 1.3; max-height: 240px; overflow: auto; }
|
||||
|
||||
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 16px 0; }
|
||||
.opt {
|
||||
background: #120c28;
|
||||
border: 3px solid var(--ink);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--ink);
|
||||
font-family: "VT323", monospace;
|
||||
font-size: 22px;
|
||||
}
|
||||
.opt.picked { border-color: var(--accent); background: #16351a; }
|
||||
.opt.good { border-color: var(--accent); background: #1d5a24; }
|
||||
.opt.bad { border-color: var(--pink); }
|
||||
|
||||
.results { margin-top: 12px; }
|
||||
.results li { list-style: none; }
|
||||
.results ul { padding: 0; }
|
||||
|
||||
.row-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.pixel-table { width: 100%; border-collapse: collapse; font-size: 18px; }
|
||||
.pixel-table th, .pixel-table td { border: 2px solid #3c2f66; padding: 6px; text-align: left; vertical-align: top; }
|
||||
.clip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.st-ready, .st-done { color: var(--accent); }
|
||||
.st-queued, .st-running, .st-needs_salary { color: var(--gold); }
|
||||
.st-error, .st-hidden { color: var(--pink); }
|
||||
|
||||
.mascot-wrap { display: grid; grid-template-columns: 110px 1fr; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||||
.mascot-wrap.compact { grid-template-columns: 80px 1fr; }
|
||||
.speech {
|
||||
background: #fff8dc;
|
||||
color: #1a1028;
|
||||
border: 4px solid #000;
|
||||
padding: 10px 12px;
|
||||
position: relative;
|
||||
min-height: 72px;
|
||||
}
|
||||
.speech::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: 18px;
|
||||
border: 8px solid transparent;
|
||||
border-right-color: #000;
|
||||
}
|
||||
|
||||
.ralo { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.ralo-name { font-family: "Press Start 2P", monospace; font-size: 8px; color: var(--accent); }
|
||||
.ralo-pixel {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transform: scale(1.5);
|
||||
transform-origin: top center;
|
||||
margin-bottom: 48px;
|
||||
animation: bounce 1.1s steps(2) infinite;
|
||||
background: #000;
|
||||
box-shadow:
|
||||
4px 0 0 #000, 8px 0 0 #000, 12px 0 0 #000,
|
||||
0 4px 0 #000, 4px 4px 0 #7dff6a, 8px 4px 0 #7dff6a, 12px 4px 0 #000, 16px 4px 0 #000,
|
||||
0 8px 0 #000, 4px 8px 0 #7dff6a, 8px 8px 0 #ffd35c, 12px 8px 0 #7dff6a, 16px 8px 0 #000,
|
||||
0 12px 0 #000, 4px 12px 0 #5ce1ff, 8px 12px 0 #7dff6a, 12px 12px 0 #5ce1ff, 16px 12px 0 #000,
|
||||
4px 16px 0 #000, 8px 16px 0 #ff5d8f, 12px 16px 0 #000,
|
||||
4px 20px 0 #000, 8px 20px 0 #000, 12px 20px 0 #000;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0% { translate: 0 0; }
|
||||
100% { translate: 0 2px; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.play-layout, .admin-grid, .grid-2, .mascot-wrap, .options { grid-template-columns: 1fr; }
|
||||
.logo { font-size: 10px; }
|
||||
}
|
||||
185
app/static/js/play.js
Normal file
185
app/static/js/play.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const root = document.querySelector(".play-layout");
|
||||
const sessionId = root.dataset.session;
|
||||
const token = root.dataset.token;
|
||||
const isHost = root.dataset.host === "1";
|
||||
const playerId = Number(root.dataset.player);
|
||||
let roundSeen = 0;
|
||||
const playersEl = document.getElementById("players");
|
||||
const stageEl = document.getElementById("stage");
|
||||
const tipEl = document.getElementById("tip");
|
||||
const inviteEl = document.getElementById("invite");
|
||||
const hostForm = document.getElementById("host-form");
|
||||
const startBtn = document.getElementById("start-btn");
|
||||
const nextBtn = document.getElementById("next-btn");
|
||||
|
||||
inviteEl.textContent = "Invite: " + location.origin + "/join/" + sessionId;
|
||||
|
||||
let lastChoice = null;
|
||||
let submitted = false;
|
||||
let proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/${sessionId}?token=${token}`);
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === "error") {
|
||||
tipEl.textContent = data.message;
|
||||
return;
|
||||
}
|
||||
if (data.type === "state") render(data);
|
||||
};
|
||||
|
||||
function send(payload) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
if (startBtn) startBtn.onclick = () => send({ action: "start" });
|
||||
if (nextBtn) nextBtn.onclick = () => send({ action: "next" });
|
||||
|
||||
function render(data) {
|
||||
const s = data.session;
|
||||
playersEl.innerHTML = data.players
|
||||
.map(
|
||||
(p) =>
|
||||
`<li class="${p.host ? "host" : ""} ${p.connected ? "" : "off"}">${escapeHtml(p.name)} — ${p.score} pts</li>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
if (hostForm) hostForm.hidden = s.status !== "lobby";
|
||||
if (startBtn) startBtn.hidden = s.status !== "lobby";
|
||||
if (nextBtn) nextBtn.hidden = !(s.status === "playing" && data.round && data.round.status === "revealed");
|
||||
|
||||
if (s.status === "lobby") {
|
||||
submitted = false;
|
||||
lastChoice = null;
|
||||
tipEl.textContent = "Lobby time! Host tweaks the rules, then hits START. Share the invite link.";
|
||||
stageEl.innerHTML = `<p>Waiting for players (${data.players.length}/${s.max_players}). Rounds: ${s.num_rounds}.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.status === "finished") {
|
||||
tipEl.textContent = `Match over! ${data.winner} takes the crown. High score wins the RAL throne.`;
|
||||
stageEl.innerHTML = `<h2>FINAL BOARD</h2>` + playersEl.outerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = data.round;
|
||||
if (!r) return;
|
||||
if (r.number !== roundSeen) {
|
||||
roundSeen = r.number;
|
||||
lastChoice = null;
|
||||
submitted = false;
|
||||
}
|
||||
if ((r.answered_ids || []).includes(playerId)) submitted = true;
|
||||
|
||||
if (r.status === "open") {
|
||||
if (r.bonus) {
|
||||
tipEl.textContent =
|
||||
"BONUS ROUND! Harder posting. 150 for the right band, 300 if you nail the exact RAL, -50 if the exact guess is wrong. Also pick hybrid / remote / in-place (+50).";
|
||||
} else {
|
||||
tipEl.textContent =
|
||||
"Read the offer. Pick the closest RAL band. 100 points if you hit it, 0 if not. Optional exact number: +200 or -50.";
|
||||
}
|
||||
} else {
|
||||
tipEl.textContent = "Everyone locked in. Here's the reveal — host can move to the next round.";
|
||||
}
|
||||
|
||||
const job = r.job;
|
||||
const meta = [];
|
||||
if (r.bonus) meta.push(`<span class="chip bonus">BONUS ROUND</span>`);
|
||||
meta.push(`<span class="chip">ROUND ${r.number}/${s.num_rounds}</span>`);
|
||||
if (job.company) meta.push(`<span class="chip">${escapeHtml(job.company)}</span>`);
|
||||
if (job.subject) meta.push(`<span class="chip">${escapeHtml(job.subject)}</span>`);
|
||||
if (job.location) meta.push(`<span class="chip">${escapeHtml(job.location)}</span>`);
|
||||
if (job.extra) meta.push(`<span class="chip">${escapeHtml(job.extra)}</span>`);
|
||||
|
||||
const locked = r.status !== "open" || submitted;
|
||||
const options = r.options
|
||||
.map((o) => {
|
||||
let cls = "opt";
|
||||
if (lastChoice === o.key) cls += " picked";
|
||||
if (r.status === "revealed") {
|
||||
if (o.key === r.correct) cls += " good";
|
||||
else if (lastChoice === o.key) cls += " bad";
|
||||
}
|
||||
return `<button class="${cls}" data-key="${o.key}" ${locked && r.status === "open" ? "disabled" : ""}>${escapeHtml(o.label)}</button>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
let extra = "";
|
||||
if (job.has_exact) {
|
||||
extra += `<label>EXACT RAL (optional)<input id="precise" type="number" min="15000" max="400000" step="1000" ${locked ? "disabled" : ""} /></label>`;
|
||||
}
|
||||
if (r.bonus) {
|
||||
extra += `<label>WORK MODE
|
||||
<select id="work-type" ${locked ? "disabled" : ""}>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
<option value="remote">Full remote</option>
|
||||
<option value="onsite">In-place only</option>
|
||||
</select>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
let results = "";
|
||||
if (r.status === "revealed") {
|
||||
const ral =
|
||||
r.ral_exact != null
|
||||
? `Exact RAL: ${r.ral_exact}`
|
||||
: `RAL range: ${r.ral_min} - ${r.ral_max}`;
|
||||
results = `<div class="results">
|
||||
<p>${ral} · Company: ${escapeHtml(r.company)} · Work: ${escapeHtml(r.work_type)}</p>
|
||||
<ul>${r.answers
|
||||
.map(
|
||||
(a) =>
|
||||
`<li>${escapeHtml(a.name)}: ${a.correct ? "HIT" : "MISS"} ${a.precise != null ? "· exact " + a.precise : ""} → ${a.points} pts</li>`
|
||||
)
|
||||
.join("")}</ul>
|
||||
</div>`;
|
||||
} else {
|
||||
results = `<p class="tiny">Locked in: ${r.answered.join(", ") || "nobody yet"}. Waiting: ${r.waiting.join(", ")}</p>`;
|
||||
}
|
||||
|
||||
stageEl.innerHTML = `
|
||||
<p class="job-title">${escapeHtml(job.title)}</p>
|
||||
<div class="meta">${meta.join("")}</div>
|
||||
<div class="description">${escapeHtml(job.description)}</div>
|
||||
<div class="options">${options}</div>
|
||||
${extra}
|
||||
${r.status === "open" ? `<button class="btn" id="lock-btn" ${locked ? "disabled" : ""}>LOCK ANSWER</button>` : ""}
|
||||
${results}
|
||||
`;
|
||||
|
||||
stageEl.querySelectorAll(".opt").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
if (locked) return;
|
||||
lastChoice = btn.dataset.key;
|
||||
stageEl.querySelectorAll(".opt").forEach((b) => b.classList.remove("picked"));
|
||||
btn.classList.add("picked");
|
||||
};
|
||||
});
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
if (lockBtn) {
|
||||
lockBtn.onclick = () => {
|
||||
if (!lastChoice) {
|
||||
tipEl.textContent = "Pick a RAL band first, then lock it in.";
|
||||
return;
|
||||
}
|
||||
const preciseEl = document.getElementById("precise");
|
||||
const workEl = document.getElementById("work-type");
|
||||
submitted = true;
|
||||
send({
|
||||
action: "answer",
|
||||
choice: lastChoice,
|
||||
precise: preciseEl && preciseEl.value ? Number(preciseEl.value) : null,
|
||||
work_type: workEl ? workEl.value : "",
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
94
app/templates/admin.html
Normal file
94
app/templates/admin.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin · Guess the RAL{% endblock %}
|
||||
{% block content %}
|
||||
<section class="admin-grid">
|
||||
<div class="pixel-panel">
|
||||
<h1>WORKER</h1>
|
||||
<p class="tiny">Playable jobs: {{ playable }} · queued/running: {{ queued }}</p>
|
||||
<form class="stack" method="post" action="/admin/fetch">
|
||||
<label>LINKEDIN JOB URL
|
||||
<input name="url" required placeholder="https://www.linkedin.com/jobs/view/..." />
|
||||
</label>
|
||||
<button class="btn" type="submit">QUEUE FETCH</button>
|
||||
</form>
|
||||
<h2>TASKS</h2>
|
||||
<table class="pixel-table">
|
||||
<tr><th>ID</th><th>STATUS</th><th>URL</th><th>ERROR</th></tr>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td>{{ t.id }}</td>
|
||||
<td class="st-{{ t.status }}">{{ t.status }}</td>
|
||||
<td class="clip">{{ t.url }}</td>
|
||||
<td class="clip">{{ t.error[:120] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="pixel-panel">
|
||||
<h1>ADD JOB</h1>
|
||||
<form class="stack" method="post" action="/admin/job">
|
||||
<label>TITLE <input name="title" required /></label>
|
||||
<label>COMPANY <input name="company" /></label>
|
||||
<div class="grid-2">
|
||||
<label>SUBJECT
|
||||
<select name="subject">
|
||||
<option>technology</option>
|
||||
<option>financial</option>
|
||||
<option>business</option>
|
||||
<option>healthcare</option>
|
||||
<option>legal</option>
|
||||
<option>hr</option>
|
||||
<option>design</option>
|
||||
<option>other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>WORK
|
||||
<select name="work_type">
|
||||
<option value="hybrid">hybrid</option>
|
||||
<option value="remote">full remote</option>
|
||||
<option value="onsite">in-place</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>LOCATION <input name="location" /></label>
|
||||
<div class="grid-2">
|
||||
<label>RAL EXACT <input name="ral_exact" /></label>
|
||||
<label>RAL MIN <input name="ral_min" /></label>
|
||||
</div>
|
||||
<label>RAL MAX <input name="ral_max" /></label>
|
||||
<label>EXTRA CLUES <input name="extra_info" /></label>
|
||||
<label>DESCRIPTION <textarea name="description" rows="5"></textarea></label>
|
||||
<button class="btn" type="submit">SAVE JOB</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pixel-panel">
|
||||
<div class="row-between">
|
||||
<h1>JOBS</h1>
|
||||
<form method="post" action="/admin/logout"><button class="btn btn-ghost" type="submit">LOGOUT</button></form>
|
||||
</div>
|
||||
<table class="pixel-table">
|
||||
<tr><th>ID</th><th>TITLE</th><th>COMPANY</th><th>RAL</th><th>TYPE</th><th>STATUS</th><th></th></tr>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td>{{ j.id }}</td>
|
||||
<td>{{ j.title }}</td>
|
||||
<td>{{ j.company }}</td>
|
||||
<td>{{ j.ral_exact or (j.ral_min and j.ral_max and (j.ral_min|string + '-' + j.ral_max|string)) or '?' }}</td>
|
||||
<td>{{ j.work_type }}</td>
|
||||
<td class="st-{{ j.status }}">{{ j.status }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/job/{{ j.id }}/status">
|
||||
<select name="status">
|
||||
<option value="ready" {% if j.status=='ready' %}selected{% endif %}>ready</option>
|
||||
<option value="needs_salary" {% if j.status=='needs_salary' %}selected{% endif %}>needs_salary</option>
|
||||
<option value="hidden" {% if j.status=='hidden' %}selected{% endif %}>hidden</option>
|
||||
</select>
|
||||
<button class="btn btn-tiny" type="submit">SET</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
14
app/templates/admin_login.html
Normal file
14
app/templates/admin_login.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin · Guess the RAL{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero pixel-panel">
|
||||
<h1>ADMIN</h1>
|
||||
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||
<form class="stack" method="post" action="/admin/login">
|
||||
<label>PASSWORD
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
<button class="btn" type="submit">ENTER</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
22
app/templates/base.html
Normal file
22
app/templates/base.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Guess the RAL{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanlines"></div>
|
||||
<header class="topbar">
|
||||
<a class="logo" href="/">GUESS THE RAL</a>
|
||||
<nav><a href="/admin">ADMIN</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
28
app/templates/index.html
Normal file
28
app/templates/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="hero pixel-panel">
|
||||
<div class="mascot-wrap">
|
||||
{% include "mascot.html" %}
|
||||
<p class="speech">Yo! I'm RALO. Read the job, hide the salary, guess the RAL. First one to the top of the board wins!</p>
|
||||
</div>
|
||||
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||
<form class="stack" method="post" action="/create">
|
||||
<label>YOUR NAME
|
||||
<input name="name" maxlength="24" required placeholder="PLAYER 1" />
|
||||
</label>
|
||||
<div class="grid-2">
|
||||
<label>MAX PLAYERS (2-12)
|
||||
<input type="number" name="max_players" min="2" max="12" value="8" />
|
||||
</label>
|
||||
<label>ROUNDS (4-32)
|
||||
<input type="number" name="num_rounds" min="4" max="32" value="8" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" name="extra_guesses" /> Extra clues from the posting</label>
|
||||
<label class="check"><input type="checkbox" name="hide_company" /> Hide company name</label>
|
||||
<label class="check"><input type="checkbox" name="hide_subject" /> Hide subject / industry</label>
|
||||
<button class="btn" type="submit">HOST MATCH</button>
|
||||
</form>
|
||||
<p class="hint">Got an invite? Open the unique link from the host.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
16
app/templates/join.html
Normal file
16
app/templates/join.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="hero pixel-panel">
|
||||
<div class="mascot-wrap">
|
||||
{% include "mascot.html" %}
|
||||
<p class="speech">Session {{ session.id[:8] }} is waiting. Drop your name and jump in!</p>
|
||||
</div>
|
||||
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||
<form class="stack" method="post" action="/join/{{ session.id }}">
|
||||
<label>YOUR NAME
|
||||
<input name="name" maxlength="24" required placeholder="PLAYER 2" />
|
||||
</label>
|
||||
<button class="btn" type="submit">JOIN MATCH</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
4
app/templates/mascot.html
Normal file
4
app/templates/mascot.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="ralo" aria-hidden="true">
|
||||
<div class="ralo-pixel"></div>
|
||||
<div class="ralo-name">RALO</div>
|
||||
</div>
|
||||
37
app/templates/play.html
Normal file
37
app/templates/play.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="play-layout" data-session="{{ session.id }}" data-token="{{ player.token }}" data-host="{{ '1' if player.is_host else '0' }}" data-player="{{ player.id }}">
|
||||
<aside class="pixel-panel board">
|
||||
<h2>PLAYERS</h2>
|
||||
<ol id="players"></ol>
|
||||
<p class="tiny" id="invite"></p>
|
||||
{% if player.is_host %}
|
||||
<form class="stack host-settings" method="post" action="/play/{{ session.id }}/settings" id="host-form">
|
||||
<h3>HOST SETTINGS</h3>
|
||||
<label>PLAYERS
|
||||
<input type="number" name="max_players" min="2" max="12" value="{{ session.max_players }}" />
|
||||
</label>
|
||||
<label>ROUNDS
|
||||
<input type="number" name="num_rounds" min="4" max="32" value="{{ session.num_rounds }}" />
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="extra_guesses" {% if session.extra_guesses %}checked{% endif %} /> Extra clues</label>
|
||||
<label class="check"><input type="checkbox" name="hide_company" {% if session.hide_company %}checked{% endif %} /> Hide company</label>
|
||||
<label class="check"><input type="checkbox" name="hide_subject" {% if session.hide_subject %}checked{% endif %} /> Hide subject</label>
|
||||
<button class="btn btn-small" type="submit">SAVE</button>
|
||||
</form>
|
||||
<button class="btn" id="start-btn" type="button">START GAME</button>
|
||||
<button class="btn btn-ghost" id="next-btn" type="button" hidden>NEXT ROUND</button>
|
||||
{% endif %}
|
||||
</aside>
|
||||
<div class="pixel-panel stage">
|
||||
<div class="mascot-wrap compact">
|
||||
{% include "mascot.html" %}
|
||||
<p class="speech" id="tip">Waiting in the lobby. The host will start the match.</p>
|
||||
</div>
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/play.js"></script>
|
||||
{% endblock %}
|
||||
73
app/worker.py
Normal file
73
app/worker.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.linkedin import fetch_linkedin_job
|
||||
from app.models import FetchTask, Job
|
||||
from app.seed import seed_jobs
|
||||
|
||||
|
||||
async def process_task(task_id: int) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.get(FetchTask, task_id)
|
||||
if not task:
|
||||
return
|
||||
task.status = "running"
|
||||
task.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
parsed = await fetch_linkedin_job(task.url)
|
||||
cols = set(Job.__table__.c.keys())
|
||||
job = Job(**{k: v for k, v in parsed.items() if k in cols and k != "id"})
|
||||
db.add(job)
|
||||
db.flush()
|
||||
task.job_id = job.id
|
||||
task.status = "done"
|
||||
task.finished_at = datetime.utcnow()
|
||||
task.error = ""
|
||||
db.commit()
|
||||
print(f"[worker] stored job {job.id} from {task.url}")
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
task = db.get(FetchTask, task_id)
|
||||
if task:
|
||||
task.status = "error"
|
||||
task.error = f"{exc}\n{traceback.format_exc()[-800:]}"
|
||||
task.finished_at = datetime.utcnow()
|
||||
db.commit()
|
||||
print(f"[worker] failed task {task_id}: {exc}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def loop() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
seed_jobs(db)
|
||||
db.close()
|
||||
print("[worker] listening for LinkedIn fetch tasks")
|
||||
while True:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = (
|
||||
db.query(FetchTask)
|
||||
.filter(FetchTask.status == "queued")
|
||||
.order_by(FetchTask.id.asc())
|
||||
.first()
|
||||
)
|
||||
task_id = task.id if task else None
|
||||
finally:
|
||||
db.close()
|
||||
if task_id:
|
||||
await process_task(task_id)
|
||||
else:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(loop())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user