first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
238
backend/app/routers/admin.py
Normal file
238
backend/app/routers/admin.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth import get_current_admin
|
||||
from app.database import get_db
|
||||
from app.models import AdminUser, EventEdition, MediaAsset, PlatformLink
|
||||
from app.schemas import (
|
||||
EventEditionCreate,
|
||||
EventEditionAdmin,
|
||||
EventEditionUpdate,
|
||||
MediaReorderItem,
|
||||
PlatformLinkCreate,
|
||||
PlatformLinkPublic,
|
||||
PlatformLinkUpdate,
|
||||
)
|
||||
from app.services.editions import edition_to_admin, edition_to_public
|
||||
from app.services.media import public_url_for_path, save_image
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(admin: AdminUser = Depends(get_current_admin)):
|
||||
return {"email": admin.email, "role": admin.role.value}
|
||||
|
||||
|
||||
# --- Platform links ---
|
||||
|
||||
|
||||
@router.get("/links", response_model=list[PlatformLinkPublic])
|
||||
def list_links(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
|
||||
rows = db.query(PlatformLink).order_by(PlatformLink.sort_order, PlatformLink.id).all()
|
||||
return [PlatformLinkPublic.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/links", response_model=PlatformLinkPublic)
|
||||
def create_link(
|
||||
body: PlatformLinkCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
row = PlatformLink(**body.model_dump())
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return PlatformLinkPublic.model_validate(row)
|
||||
|
||||
|
||||
@router.patch("/links/{link_id}", response_model=PlatformLinkPublic)
|
||||
def update_link(
|
||||
link_id: int,
|
||||
body: PlatformLinkUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(row, k, v)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return PlatformLinkPublic.model_validate(row)
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
def delete_link(link_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
|
||||
row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- Editions ---
|
||||
|
||||
|
||||
@router.get("/editions", response_model=list[EventEditionAdmin])
|
||||
def admin_list_editions(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
|
||||
editions = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.order_by(EventEdition.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [edition_to_admin(e) for e in editions]
|
||||
|
||||
|
||||
@router.post("/editions", response_model=EventEditionAdmin)
|
||||
def create_edition(
|
||||
body: EventEditionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
if db.query(EventEdition).filter(EventEdition.slug == body.slug).first():
|
||||
raise HTTPException(status_code=400, detail="Slug already exists")
|
||||
edition = EventEdition(city="Bengaluru", **body.model_dump())
|
||||
db.add(edition)
|
||||
db.commit()
|
||||
db.refresh(edition)
|
||||
edition = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.id == edition.id)
|
||||
.one()
|
||||
)
|
||||
return edition_to_admin(edition)
|
||||
|
||||
|
||||
@router.patch("/editions/{edition_id}", response_model=EventEditionAdmin)
|
||||
def update_edition(
|
||||
edition_id: int,
|
||||
body: EventEditionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "slug" in data and data["slug"] != edition.slug:
|
||||
if db.query(EventEdition).filter(EventEdition.slug == data["slug"]).first():
|
||||
raise HTTPException(status_code=400, detail="Slug already exists")
|
||||
for k, v in data.items():
|
||||
setattr(edition, k, v)
|
||||
db.commit()
|
||||
edition = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.id == edition_id)
|
||||
.one()
|
||||
)
|
||||
return edition_to_admin(edition)
|
||||
|
||||
|
||||
@router.post("/editions/{edition_id}/cover")
|
||||
async def upload_cover(
|
||||
edition_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
try:
|
||||
path = save_image(file)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
edition.cover_image_path = path
|
||||
db.commit()
|
||||
return {"cover_image_url": public_url_for_path(path)}
|
||||
|
||||
|
||||
@router.post("/editions/{edition_id}/media")
|
||||
async def upload_media(
|
||||
edition_id: int,
|
||||
file: UploadFile = File(...),
|
||||
caption: str | None = Form(default=None),
|
||||
sort_order: int = Form(default=0),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
try:
|
||||
path = save_image(file)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
asset = MediaAsset(
|
||||
edition_id=edition_id,
|
||||
file_path=path,
|
||||
caption=caption,
|
||||
sort_order=sort_order,
|
||||
is_published=True,
|
||||
)
|
||||
db.add(asset)
|
||||
db.commit()
|
||||
db.refresh(asset)
|
||||
return {
|
||||
"id": asset.id,
|
||||
"url": public_url_for_path(path),
|
||||
"caption": asset.caption,
|
||||
"sort_order": asset.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/media/{media_id}")
|
||||
def update_media(
|
||||
media_id: int,
|
||||
caption: str | None = Form(default=None),
|
||||
sort_order: int | None = Form(default=None),
|
||||
is_published: bool | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Media not found")
|
||||
if caption is not None:
|
||||
asset.caption = caption
|
||||
if sort_order is not None:
|
||||
asset.sort_order = sort_order
|
||||
if is_published is not None:
|
||||
asset.is_published = is_published
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/editions/{edition_id}/media/reorder")
|
||||
def reorder_media(
|
||||
edition_id: int,
|
||||
items: list[MediaReorderItem],
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
for item in items:
|
||||
asset = (
|
||||
db.query(MediaAsset)
|
||||
.filter(MediaAsset.id == item.id, MediaAsset.edition_id == edition_id)
|
||||
.first()
|
||||
)
|
||||
if asset:
|
||||
asset.sort_order = item.sort_order
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/media/{media_id}")
|
||||
def delete_media(media_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Media not found")
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
44
backend/app/routers/admin_users.py
Normal file
44
backend/app/routers/admin_users.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import hash_password, require_super_admin
|
||||
from app.database import get_db
|
||||
from app.models import AdminUser
|
||||
from app.schemas import AdminUserCreate, AdminUserPublic, SubscriberPublic
|
||||
from app.models import NewsletterSubscriber
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin-users"])
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserPublic])
|
||||
def list_users(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)):
|
||||
return db.query(AdminUser).order_by(AdminUser.id).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserPublic)
|
||||
def create_user(
|
||||
body: AdminUserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(require_super_admin),
|
||||
):
|
||||
if db.query(AdminUser).filter(AdminUser.email == body.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = AdminUser(
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
role=body.role,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return AdminUserPublic.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/subscribers", response_model=list[SubscriberPublic])
|
||||
def list_subscribers(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)):
|
||||
return (
|
||||
db.query(NewsletterSubscriber)
|
||||
.order_by(NewsletterSubscriber.id.desc())
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
40
backend/app/routers/analytics.py
Normal file
40
backend/app/routers/analytics.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_admin
|
||||
from app.database import get_db
|
||||
from app.models import AdminUser, OutboundClick
|
||||
from app.schemas import ClickSummaryItem, OutboundClickPublic
|
||||
|
||||
router = APIRouter(prefix="/api/admin/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@router.get("/clicks/summary", response_model=list[ClickSummaryItem])
|
||||
def click_summary(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=max(1, min(days, 365)))
|
||||
rows = (
|
||||
db.query(OutboundClick.link_kind, func.count(OutboundClick.id))
|
||||
.filter(OutboundClick.created_at >= since)
|
||||
.group_by(OutboundClick.link_kind)
|
||||
.order_by(func.count(OutboundClick.id).desc())
|
||||
.all()
|
||||
)
|
||||
return [ClickSummaryItem(link_kind=kind, count=count) for kind, count in rows]
|
||||
|
||||
|
||||
@router.get("/clicks/recent", response_model=list[OutboundClickPublic])
|
||||
def recent_clicks(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
_: AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
limit = max(1, min(limit, 200))
|
||||
rows = db.query(OutboundClick).order_by(OutboundClick.id.desc()).limit(limit).all()
|
||||
return [OutboundClickPublic.model_validate(r) for r in rows]
|
||||
17
backend/app/routers/auth.py
Normal file
17
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import create_access_token, verify_password
|
||||
from app.database import get_db
|
||||
from app.models import AdminUser
|
||||
from app.schemas import LoginRequest, Token
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(AdminUser).filter(AdminUser.email == body.email).first()
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
return Token(access_token=create_access_token(user.email))
|
||||
151
backend/app/routers/public.py
Normal file
151
backend/app/routers/public.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import EventEdition, EventType, NewsletterSubscriber, OutboundClick, PlatformLink
|
||||
from app.schemas import EventEditionPublic, PlatformLinkPublic, SiteConfigPublic, SubscribeRequest
|
||||
from app.services.alerts import maybe_notify_click_spike
|
||||
from app.services.calendar import build_ics
|
||||
from app.services.editions import edition_to_public
|
||||
from app.services.outbound import validate_outbound_url
|
||||
from app.services.share_preview import render_share_html
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
TAGLINE = "Bengaluru's home for Holi, Garba, Dandiya & bhajan nights — across the city's best venues."
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "city": "Bengaluru"}
|
||||
|
||||
|
||||
@router.get("/config", response_model=SiteConfigPublic)
|
||||
def site_config(db: Session = Depends(get_db)):
|
||||
links = (
|
||||
db.query(PlatformLink)
|
||||
.filter(PlatformLink.is_active.is_(True))
|
||||
.order_by(PlatformLink.sort_order, PlatformLink.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
editions = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.is_published.is_(True))
|
||||
.order_by(EventEdition.is_featured.desc(), EventEdition.sort_order, EventEdition.event_date.desc().nullslast())
|
||||
.all()
|
||||
)
|
||||
|
||||
featured = next((e for e in editions if e.is_featured), editions[0] if editions else None)
|
||||
recent = [edition_to_public(e) for e in editions[:6]]
|
||||
|
||||
return SiteConfigPublic(
|
||||
city="Bengaluru",
|
||||
tagline=TAGLINE,
|
||||
platform_links=[PlatformLinkPublic.model_validate(l) for l in links],
|
||||
featured_edition=edition_to_public(featured) if featured else None,
|
||||
recent_editions=recent,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/editions", response_model=list[EventEditionPublic])
|
||||
def list_editions(
|
||||
db: Session = Depends(get_db),
|
||||
event_type: EventType | None = Query(default=None),
|
||||
):
|
||||
q = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.is_published.is_(True))
|
||||
)
|
||||
if event_type is not None:
|
||||
q = q.filter(EventEdition.event_type == event_type)
|
||||
editions = q.order_by(EventEdition.event_date.desc().nullslast(), EventEdition.id.desc()).all()
|
||||
return [edition_to_public(e) for e in editions]
|
||||
|
||||
|
||||
@router.get("/editions/{slug}", response_model=EventEditionPublic)
|
||||
def get_edition(slug: str, db: Session = Depends(get_db)):
|
||||
edition = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
return edition_to_public(edition)
|
||||
|
||||
|
||||
@router.get("/go")
|
||||
def outbound_redirect(
|
||||
request: Request,
|
||||
url: str = Query(..., min_length=8, max_length=512),
|
||||
kind: str = Query(default="other", max_length=64),
|
||||
edition_id: int | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
destination = validate_outbound_url(url)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid destination URL")
|
||||
|
||||
db.add(
|
||||
OutboundClick(
|
||||
link_kind=kind[:64],
|
||||
destination_url=destination,
|
||||
edition_id=edition_id,
|
||||
referrer=request.headers.get("referer"),
|
||||
user_agent=(request.headers.get("user-agent") or "")[:512],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
maybe_notify_click_spike(db)
|
||||
return RedirectResponse(destination, status_code=302)
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
def subscribe(body: SubscribeRequest, db: Session = Depends(get_db)):
|
||||
existing = db.query(NewsletterSubscriber).filter(NewsletterSubscriber.email == body.email).first()
|
||||
if existing:
|
||||
return {"ok": True, "message": "Already on the list — we'll see you in Bengaluru."}
|
||||
db.add(NewsletterSubscriber(email=body.email, neighborhood=body.neighborhood))
|
||||
db.commit()
|
||||
return {"ok": True, "message": "You're in! We'll email you when the next edition drops."}
|
||||
|
||||
|
||||
@router.get("/editions/{slug}/calendar.ics", response_class=PlainTextResponse)
|
||||
def edition_calendar(slug: str, db: Session = Depends(get_db)):
|
||||
edition = (
|
||||
db.query(EventEdition)
|
||||
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}"
|
||||
return PlainTextResponse(
|
||||
build_ics(edition, page_url),
|
||||
media_type="text/calendar",
|
||||
headers={"Content-Disposition": f'attachment; filename="{slug}.ics"'},
|
||||
)
|
||||
|
||||
|
||||
def _share_html_for_slug(slug: str, db: Session) -> str:
|
||||
edition = (
|
||||
db.query(EventEdition)
|
||||
.options(joinedload(EventEdition.media))
|
||||
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not edition:
|
||||
raise HTTPException(status_code=404, detail="Edition not found")
|
||||
return render_share_html(edition_to_public(edition), slug)
|
||||
|
||||
|
||||
@router.get("/share/events/{slug}", response_class=HTMLResponse)
|
||||
def share_event_preview_api(slug: str, db: Session = Depends(get_db)):
|
||||
return HTMLResponse(_share_html_for_slug(slug, db))
|
||||
13
backend/app/routers/share.py
Normal file
13
backend/app/routers/share.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.routers.public import _share_html_for_slug
|
||||
|
||||
router = APIRouter(tags=["share"])
|
||||
|
||||
|
||||
@router.get("/share/events/{slug}", response_class=HTMLResponse)
|
||||
def share_event_short_url(slug: str, db: Session = Depends(get_db)):
|
||||
return HTMLResponse(_share_html_for_slug(slug, db))
|
||||
Loading…
Add table
Add a link
Reference in a new issue