diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 3 | ||||
-rw-r--r-- | app.py | 179 | ||||
-rw-r--r-- | static/favicon.ico | bin | 0 -> 318 bytes | |||
-rw-r--r-- | static/styles.css | 34 | ||||
-rw-r--r-- | templates/event.html | 96 | ||||
-rw-r--r-- | templates/index.html | 40 |
7 files changed, 354 insertions, 0 deletions
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 @@ -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 Binary files differnew file mode 100644 index 0000000..18c2791 --- /dev/null +++ b/static/favicon.ico 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 @@ +<!DOCTYPE html> +<html> + <head> + <title>Symposium</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="stylesheet" type="text/css" href="/static/styles.css"> + <link rel="stylesheet" type="text/css" href="/symposium/static/styles.css"> + <link rel="shortcut icon" type="image/x-icon" href="/symposium/static/favicon.ico"> + </head> + <body> + <div class="container"> + <div> + <div class="home"> + <a href="/symposium" class="home-title">Symposium</a> + <span> at </span><a class="site" href="/">cyfraeviolae.org</a> + <a class="source" href="/git/symposium">[src]</a> + </div> + </div> + <br> + {% if manage %} + <span class="manage">Admin Management Interface</span> + <br> + <br> + Save your admin management link. Without it, you will not be able to edit your event. + <br> + <br> + <button onclick="navigator.clipboard.writeText(document.location.host + '/symposium/event/{{event.iden}}?password={{event.password}}')">Copy admin management link (keep safe)</button> + <br> + <br> + <button onclick="navigator.clipboard.writeText(document.location.host + '/symposium/event/{{event.iden}}')">Copy invite link</button> + <button onclick="window.open('/symposium/event/{{event.iden}}', '_blank')">Visit public invite page</button> + <br> + <hr> + {% endif %} + <form method="POST" action="/symposium/event/{{ event.iden }}/edit?password={{event.password}}"> + {% if manage %} + <span class="q">Title:</span> + <input name="title" type="text" value="{{ event.title }}"></input> + {% else %} + <h3>{{ event.title }}</h3> + {% endif %} + <br> + {% if not manage %} + <button>Copy invite link</button> + <br> + {% endif %} + <br> + <span class="q">When?</span> + {% if manage %} + <input name="when" class="when" type="datetime-local" value="{{ event.time }}" > + {% else %} + <input name="when" class="when" type="datetime-local" value="{{ event.time }}" disabled> + {% endif %} + <br> + <br> + + <span class="q">Where?</span> + {% if manage %} + <input name="where" type="text" value="{{ event.location }}"></input> + {% else %} + <a target="_blank" href="http://maps.google.com/?q={{ event.location }}">{{ event.location }}</a> + {% endif %} + + <br><br> + + <span class="q">What?</span> + <br> + {% if manage %} + <textarea name="what" class="desc">{{event.description}}</textarea> + <br> + {% else %} + {{ event.description }} + <br> + {% endif %} + + <br> + {% if manage %} + <button>Save changes</button> + <br> + <br> + {% endif %} + </form> + <span class="q">Who?</span> + <br> + {% for name in event.invites %} + — {{ name }} + <br> + {% endfor %} + <form method="POST" action="/symposium/event/{{ event.iden }}/join" class="join-form"> + <input type="text" name="name"> + <button type="submit">Add attendee</button> + </form> + </div> + </body> +</html> 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 @@ +<!DOCTYPE html> +<html> + <head> + <title>Symposium</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="stylesheet" type="text/css" href="/static/styles.css"> + <link rel="stylesheet" type="text/css" href="/symposium/static/styles.css"> + <link rel="shortcut icon" type="image/x-icon" href="/symposium/static/favicon.ico"> + </head> + <body> + <div class="container"> + <div> + <div class="home"> + <a href="/symposium" class="home-title">Symposium</a> + <span> at </span><a class="site" href="/">cyfraeviolae.org</a> + <a class="source" href="/git/symposium">[src]</a> + </div> + </div> + + <br> + <form method="POST" action="/symposium/event/create"> + <span class="q">Title:</span> + <input name="title" type="text" required></input> + <br> + <span class="q">When?</span> + <input name="when" class="when" type="datetime-local" required> + <br> + <span class="q">Where?</span> + <input name="where" type="text" required></input> + <br> + <span class="q">What?</span> + <br> + <textarea name="what" class="desc" required></textarea> + <br> + <button>Create event</button> + </form> + </div> + </body> +</html> |