From 5b620cacf2cdbfd1f720ffe68affe9d429031d8a Mon Sep 17 00:00:00 2001 From: cyfraeviolae Date: Wed, 3 Apr 2024 02:50:54 -0400 Subject: init --- .gitignore | 2 + README.md | 3 + app.py | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++ static/favicon.ico | Bin 0 -> 318 bytes static/styles.css | 34 ++++++++++ templates/event.html | 96 +++++++++++++++++++++++++++ templates/index.html | 40 ++++++++++++ 7 files changed, 354 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 static/favicon.ico create mode 100644 static/styles.css create mode 100644 templates/event.html create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd22805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sqlite +venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..373cb57 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# symposium + +event RSVP organizer diff --git a/app.py b/app.py new file mode 100644 index 0000000..cffe64c --- /dev/null +++ b/app.py @@ -0,0 +1,179 @@ +from pathlib import Path +import hmac +from typing import Annotated +import secrets +import random +from enum import Enum +from dataclasses import dataclass +import datetime +from contextlib import asynccontextmanager +from typing import Any +from collections.abc import AsyncGenerator, Sequence + +from sqlalchemy import select, String +from sqlalchemy.exc import IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.dialects.postgresql import ARRAY + +from litestar import Litestar, get, post, Request +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.exceptions import HTTPException +from litestar.template.config import TemplateConfig +from litestar.response import Template, Redirect +from litestar.static_files import create_static_files_router +from litestar.enums import RequestEncodingType +from litestar.params import Body +from litestar import Request +from litestar.datastructures import State + +# link location to gmaps +# automake calendar invite ics +# persistence +# use url_fors +# timezones? + +class Base(DeclarativeBase): + pass + +class Event(Base): + __tablename__ = "events" + + def __init__(self, iden, password, title, time, location, description): + self.iden = iden + self.password = password + self.title = title + self.time = time + self.location = location + self.description = description + self.invites = '[]' + + iden: Mapped[str] = mapped_column(primary_key=True) + password: Mapped[str] + title: Mapped[str] + time: Mapped[datetime.datetime] + location: Mapped[str] + description: Mapped[str] + invites: Mapped[str] + +@asynccontextmanager +async def db_connection(app: Litestar) -> AsyncGenerator[None, None]: + engine = getattr(app.state, "engine", None) + if engine is None: + engine = create_async_engine("sqlite+aiosqlite:///symposium.sqlite", echo=True) + app.state.engine = engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + try: + yield + finally: + await engine.dispose() + +sessionmaker = async_sessionmaker(expire_on_commit=False) + +parts = [ + "Oxbow", "Limber", "Steadfast", "Regicide", "Swarm", "Weave", "Bough", "Canopy", "Herald", "Scorn", + "Alder", "Aerial", "Welkin", "Acrid", "Kindling", "Rapture", "Myrtle", "Envy", "Solstice", + "Juniper", "Cleaving", "Stream", "Reaper", "Sluice", "Conduit", "Disdain", "Sylvan", "Ravish", "Atrium", + "Thresh", "Harvest", "Water", "Renewal", "Rosy", "Frieze", "Portal", "Vespers", "Litany", "Serpent", + "Primate", "Incite", "Canon", "Acquiese", "Mirror", "Script", "Seal", "Privy", + "Piercing", "Heresy", "Subduct", "Sceptre", "Arrogance", "Ivory", "Accrete", "Cluster", + "Sepulchre", "Summon", "Pleading", "Myriad", "Exalted", "Sentry", "Shriven", "River", "Threshold" +] + +def gen_iden(): + return "-".join(random.choices(parts, k=8)) + +iden = 'Weave-Stream-Limber-Exalted-Sluice-Reaper-Myrtle-Incite' + +title = 'Hot Pot at the Cafe Cyfrae Violae' +when = datetime.datetime.fromisoformat("2024-04-07T13:00:00") +where = '133 E 4th St, Apt 6, New York NY 10003' +what = "Hot pot, Anxi oolong, baijiu, Hua Zhou." +EVENTS = {} + +@get("/") +async def index() -> Template: + return Template(template_name="index.html") + +@get("/event/{iden:str}") +async def event(state: State, iden: str, password: str = "") -> Template: + async with sessionmaker(bind=state.engine) as session: + async with session.begin(): + query = select(Event).where(Event.iden == iden) + result = await session.execute(query) + event = result.scalar_one() + + manage = False + if password and hmac.compare_digest(event.password, password): + manage = True + + context = dict(event=event, manage=manage) + return Template(template_name="event.html", context=context) + +@dataclass +class EditRequest: + title: str + when: str + where: str + what: str + +@post("/event/create") +async def create(state: State, data: Annotated[EditRequest, Body(media_type=RequestEncodingType.URL_ENCODED)]) -> Redirect: + iden = gen_iden() + password = secrets.token_bytes(16).hex() + event = Event(iden, password, data.title, datetime.datetime.fromisoformat(data.when), data.where, data.what) + + async with sessionmaker(bind=state.engine) as session: + async with session.begin(): + session.add(event) + + return Redirect(path="/symposium/event/" + iden + "?password=" + event.password) + +@post("/event/{iden:str}/edit") +async def edit(request: Request, iden: str, password: str, data: Annotated[EditRequest, Body(media_type=RequestEncodingType.URL_ENCODED)]) -> Redirect: #-> Template: + event = EVENTS[iden] + + manage = False + if password and hmac.compare_digest(event.password, password): + manage = True + if not manage: + raise ValueError("no auth") + + event.title = data.title + event.time = data.when + event.location = data.where + event.description = data.what + + return Redirect(path="/symposium/event/" + iden + "?password=" + event.password) + +@dataclass +class JoinRequest: + name: str + +@post("/event/{iden:str}/join") +async def join(request: Request, iden: str, data: Annotated[JoinRequest, Body(media_type=RequestEncodingType.URL_ENCODED)]) -> Redirect: #-> Template: + event = EVENTS[iden] + name = data.name + # TODO if name exists reject + invites = json.loads(event.invites) + invites.append(name) + event.invites = json.dumps(invites) + return Redirect(path="/symposium/event/" + iden) + +app = Litestar( + route_handlers=[ + index, + event, + create, + edit, + join, + create_static_files_router(path='/static', directories=['static']), + ], + template_config=TemplateConfig( + directory=Path("templates"), + engine=JinjaTemplateEngine, + ), + lifespan=[db_connection], + debug=True, +) diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..18c2791 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..cce5d61 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,34 @@ +.q { + font-style: italic; + font-weight: bold; +} + +.join-form { + margin-top: .5em; +} + +.manage { + font-style: italic; + font-weight: bold; + color: DarkRed; +} + +h3 { + padding-top: 0; + margin-top: 0; + padding-bottom: 0; + margin-bottom: 0; +} + +.desc { + margin-top: 1em; + width: 80%; +} + +input { + width: 80%; +} + +.when { + width: fit-content; +} diff --git a/templates/event.html b/templates/event.html new file mode 100644 index 0000000..2440f42 --- /dev/null +++ b/templates/event.html @@ -0,0 +1,96 @@ + + + + Symposium + + + + + + + +
+
+ +
+
+ {% if manage %} + Admin Management Interface +
+
+ Save your admin management link. Without it, you will not be able to edit your event. +
+
+ +
+
+ + +
+
+ {% endif %} +
+ {% if manage %} + Title: + + {% else %} +

{{ event.title }}

+ {% endif %} +
+ {% if not manage %} + +
+ {% endif %} +
+ When? + {% if manage %} + + {% else %} + + {% endif %} +
+
+ + Where? + {% if manage %} + + {% else %} + {{ event.location }} + {% endif %} + +

+ + What? +
+ {% if manage %} + +
+ {% else %} + {{ event.description }} +
+ {% endif %} + +
+ {% if manage %} + +
+
+ {% endif %} +
+ Who? +
+ {% for name in event.invites %} + — {{ name }} +
+ {% endfor %} +
+ + +
+
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..186e657 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,40 @@ + + + + Symposium + + + + + + + +
+
+ +
+ +
+
+ Title: + +
+ When? + +
+ Where? + +
+ What? +
+ +
+ +
+
+ + -- cgit v1.2.3