summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md3
-rw-r--r--app.py179
-rw-r--r--static/favicon.icobin0 -> 318 bytes
-rw-r--r--static/styles.css34
-rw-r--r--templates/event.html96
-rw-r--r--templates/index.html40
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
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
--- /dev/null
+++ b/static/favicon.ico
Binary files 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 @@
+<!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 %}
+ &mdash; {{ 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>