Initial commit.
This commit is contained in:
commit
4e5ee195dd
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
__pycache__
|
28
confarchive/__init__.py
Normal file
28
confarchive/__init__.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
durations = [
|
||||||
|
"5 seconds",
|
||||||
|
"25 seconds",
|
||||||
|
"2 minutes",
|
||||||
|
"10 minutes",
|
||||||
|
"1 hour",
|
||||||
|
"5 hours",
|
||||||
|
"1 day",
|
||||||
|
"5 days",
|
||||||
|
"25 days",
|
||||||
|
"4 months",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def rd(label):
|
||||||
|
num, _, unit = label.partition(" ")
|
||||||
|
if not unit.endswith("s"):
|
||||||
|
unit += "s"
|
||||||
|
return relativedelta(**{unit: int(num)})
|
||||||
|
|
||||||
|
|
||||||
|
bins = (
|
||||||
|
[{"label": None, "delta": None}]
|
||||||
|
+ [{"label": label, "delta": rd(label)} for label in durations]
|
||||||
|
+ [{"label": "Never", "delta": None}]
|
||||||
|
)
|
30
confarchive/database.py
Normal file
30
confarchive/database.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import flask
|
||||||
|
import sqlalchemy
|
||||||
|
from sqlalchemy import create_engine, func
|
||||||
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
|
|
||||||
|
session = scoped_session(sessionmaker())
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_url: str, echo: bool = False) -> None:
|
||||||
|
"""Initialise databsae."""
|
||||||
|
session.configure(bind=get_engine(db_url, echo=echo))
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(db_url: str, echo: bool = False) -> sqlalchemy.engine.base.Engine:
|
||||||
|
"""Create an engine object."""
|
||||||
|
return create_engine(db_url, pool_recycle=3600, echo=echo)
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app: flask.app.Flask, echo: bool = False) -> None:
|
||||||
|
"""Initialise database connection within flask app."""
|
||||||
|
db_url = app.config["DB_URL"]
|
||||||
|
session.configure(bind=get_engine(db_url, echo=echo))
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def shutdown_session(exception: Exception | None = None) -> None:
|
||||||
|
session.remove()
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc():
|
||||||
|
return func.timezone("utc", func.now())
|
101
confarchive/model.py
Normal file
101
confarchive/model.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
"""Database models."""
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import sqlalchemy.orm.decl_api
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.schema import Column, ForeignKey
|
||||||
|
from sqlalchemy.types import Date, DateTime, Integer, String
|
||||||
|
|
||||||
|
from .database import session
|
||||||
|
|
||||||
|
Base: sqlalchemy.orm.decl_api.DeclarativeMeta = declarative_base()
|
||||||
|
Base.query = session.query_property()
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStampedModel(Base):
|
||||||
|
"""Time stamped model."""
|
||||||
|
|
||||||
|
__abstract__ = True
|
||||||
|
created = Column(DateTime, default=func.now())
|
||||||
|
modified = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Conference(TimeStampedModel):
|
||||||
|
"""Conference."""
|
||||||
|
|
||||||
|
__tablename__ = "conference"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
start = Column(Date)
|
||||||
|
end = Column(Date)
|
||||||
|
days = Column(Integer)
|
||||||
|
timezone = Column(String)
|
||||||
|
location = Column(String)
|
||||||
|
country = Column(String)
|
||||||
|
acronym = Column(String)
|
||||||
|
url = Column(String)
|
||||||
|
schedule_xml_url = Column(String)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(TimeStampedModel):
|
||||||
|
"""Event."""
|
||||||
|
|
||||||
|
__tablename__ = "event"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
conference_id = Column(Integer, ForeignKey("conference.id"), nullable=False)
|
||||||
|
event_date = Column(DateTime)
|
||||||
|
day = Column(Integer)
|
||||||
|
guid = Column(String)
|
||||||
|
start = Column(String)
|
||||||
|
duration = Column(String)
|
||||||
|
room = Column(String)
|
||||||
|
track = Column(String)
|
||||||
|
slug = Column(String)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
description = Column(String)
|
||||||
|
event_type = Column(String)
|
||||||
|
url = Column(String)
|
||||||
|
|
||||||
|
conference = relationship("Conference", backref="events")
|
||||||
|
|
||||||
|
people_association = relationship(
|
||||||
|
"EventPerson",
|
||||||
|
order_by="EventPerson.position",
|
||||||
|
back_populates="event",
|
||||||
|
collection_class=ordering_list("position"),
|
||||||
|
)
|
||||||
|
people = association_proxy(
|
||||||
|
"people_association",
|
||||||
|
"person",
|
||||||
|
creator=lambda person: EventPerson(person=person),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Person(TimeStampedModel):
|
||||||
|
"""Person."""
|
||||||
|
|
||||||
|
__tablename__ = "person"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String)
|
||||||
|
wikidata_qid = Column(String)
|
||||||
|
gender = Column(String)
|
||||||
|
|
||||||
|
events_association = relationship("EventPerson", back_populates="person")
|
||||||
|
events = association_proxy("event_association", "event")
|
||||||
|
|
||||||
|
|
||||||
|
class EventPerson(Base):
|
||||||
|
"""Event person."""
|
||||||
|
|
||||||
|
__tablename__ = "event_person"
|
||||||
|
event_id = Column(Integer, ForeignKey("event.id"), primary_key=True)
|
||||||
|
person_id = Column(Integer, ForeignKey("person.id"), primary_key=True)
|
||||||
|
position = Column(Integer, nullable=False)
|
||||||
|
named_as = Column(String)
|
||||||
|
|
||||||
|
person = relationship("Person", back_populates="events_association")
|
||||||
|
event = relationship("Event", back_populates="people_association")
|
7
config/default.py
Normal file
7
config/default.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
DB_URL = "postgresql:///confarchive"
|
||||||
|
SECRET_KEY = '67e49de8fa5d1fe0d767076f382f098176ae17721c90836f'
|
||||||
|
|
||||||
|
ADMINS = ['edward@4angle.com']
|
||||||
|
SMTP_HOST = 'localhost'
|
||||||
|
MAIL_FROM = 'edward@4angle.com'
|
||||||
|
ADMIN_EMAIL = 'edward@4angle.com'
|
6
config/sample.py
Normal file
6
config/sample.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
DB_URL = "postgresql:///spaced"
|
||||||
|
SECRET_KEY = "YOUR-SECRET-KEY"
|
||||||
|
|
||||||
|
ADMINS = ["admin@example.org"]
|
||||||
|
SMTP_HOST = "localhost"
|
||||||
|
MAIL_FROM = "admin@example.org"
|
15
create_db.py
Executable file
15
create_db.py
Executable file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from confarchive import database, model
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
|
||||||
|
def create_db() -> None:
|
||||||
|
"""Create database."""
|
||||||
|
app
|
||||||
|
engine = database.session.get_bind()
|
||||||
|
model.Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_db()
|
153
load_conference.py
Executable file
153
load_conference.py
Executable file
|
@ -0,0 +1,153 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import lxml.etree
|
||||||
|
|
||||||
|
from confarchive import database, model
|
||||||
|
|
||||||
|
DB_URL = "postgresql:///confarchive"
|
||||||
|
|
||||||
|
database.init_db(DB_URL)
|
||||||
|
|
||||||
|
Element = lxml.etree._Element
|
||||||
|
|
||||||
|
meals = {"lunch", "dinner", "breakfast"}
|
||||||
|
non_talk_titles = {"afternoon break", "cheese and wine party", "debcamp", "job fair"}
|
||||||
|
|
||||||
|
|
||||||
|
def not_a_talk(title: str) -> bool:
|
||||||
|
"""Event with this title is not a talk."""
|
||||||
|
return is_meal(title) or title.lower() in non_talk_titles
|
||||||
|
|
||||||
|
|
||||||
|
def is_meal(title: str) -> bool:
|
||||||
|
"""Event title represents a meal."""
|
||||||
|
return title.lower() in meals
|
||||||
|
|
||||||
|
|
||||||
|
def read_field(root: Element, field: str) -> str | None:
|
||||||
|
"""Get conference field."""
|
||||||
|
value = root.findtext(".//" + field)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
assert isinstance(value, str)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def read_date_field(root: Element, field: str) -> datetime.date | None:
|
||||||
|
"""Read date from a field."""
|
||||||
|
value = read_field(root, field)
|
||||||
|
return parse_isodate(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_required_field(root: Element, field: str) -> str:
|
||||||
|
"""Read a required field."""
|
||||||
|
value = read_field(root, field)
|
||||||
|
assert value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_isodate(iso_date: str) -> datetime.date:
|
||||||
|
"""Read a date in ISO format."""
|
||||||
|
return datetime.datetime.fromisoformat(iso_date).date()
|
||||||
|
|
||||||
|
|
||||||
|
def conference_obj(root: Element) -> model.Conference:
|
||||||
|
"""Build conference object."""
|
||||||
|
e = root.find(".//conference")
|
||||||
|
assert e is not None
|
||||||
|
|
||||||
|
return model.Conference(
|
||||||
|
title=read_required_field(e, "title"),
|
||||||
|
start=read_date_field(e, "start"),
|
||||||
|
end=read_date_field(e, "end"),
|
||||||
|
timezone=read_field(e, "time_zone_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_event_object(
|
||||||
|
e: Element, person_lookup: dict[int, model.Person]
|
||||||
|
) -> model.Event | None:
|
||||||
|
"""Build an event object."""
|
||||||
|
title = read_required_field(e, "title")
|
||||||
|
if not_a_talk(title):
|
||||||
|
return None
|
||||||
|
|
||||||
|
room = read_field(e, "room")
|
||||||
|
slug = read_field(e, "slug")
|
||||||
|
description = read_field(e, "description")
|
||||||
|
event_type = read_field(e, "type")
|
||||||
|
url = read_field(e, "url")
|
||||||
|
|
||||||
|
persons_element = e.find(".//persons")
|
||||||
|
if persons_element is None:
|
||||||
|
return None
|
||||||
|
people = []
|
||||||
|
for p in persons_element:
|
||||||
|
id_str = p.get("id")
|
||||||
|
assert id_str is not None
|
||||||
|
people.append(person_lookup[int(id_str)])
|
||||||
|
|
||||||
|
print(title, people)
|
||||||
|
|
||||||
|
return model.Event(
|
||||||
|
title=title,
|
||||||
|
room=room,
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
event_type=event_type,
|
||||||
|
url=url,
|
||||||
|
people=people,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_people(root: Element) -> list[tuple[int, str]]:
|
||||||
|
people: dict[int, str] = {}
|
||||||
|
for person in root.findall(".//person"):
|
||||||
|
assert person.text
|
||||||
|
person_id_str = person.get("id")
|
||||||
|
assert person_id_str
|
||||||
|
person_id = int(person_id_str)
|
||||||
|
existing = people.get(person_id)
|
||||||
|
if existing:
|
||||||
|
assert person.text == existing
|
||||||
|
continue
|
||||||
|
people[person_id] = person.text
|
||||||
|
|
||||||
|
return sorted(people.items())
|
||||||
|
|
||||||
|
|
||||||
|
def load(filename: str) -> None:
|
||||||
|
"""Load conference schedule."""
|
||||||
|
root = lxml.etree.parse(filename).getroot()
|
||||||
|
conf = conference_obj(root)
|
||||||
|
database.session.add(conf)
|
||||||
|
|
||||||
|
event_count = 0
|
||||||
|
people = get_all_people(root)
|
||||||
|
person_lookup = {}
|
||||||
|
for person_id, name in people:
|
||||||
|
person = model.Person.query.filter_by(name=name).first()
|
||||||
|
if not person:
|
||||||
|
person = model.Person(name=name)
|
||||||
|
database.session.add(person)
|
||||||
|
person_lookup[person_id] = person
|
||||||
|
|
||||||
|
for day in root.findall(".//day"):
|
||||||
|
for room in root.findall(".//room"):
|
||||||
|
for event_element in root.findall(".//event"):
|
||||||
|
event = build_event_object(event_element, person_lookup)
|
||||||
|
if not event:
|
||||||
|
continue
|
||||||
|
event.conference = conf
|
||||||
|
print()
|
||||||
|
database.session.add(event)
|
||||||
|
event_count += 1
|
||||||
|
if event_count > 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
load("/home/edward/src/2022/conference-gender-mix/schedules/debconf22")
|
||||||
|
|
||||||
|
database.session.commit()
|
21
main.py
Executable file
21
main.py
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import flask
|
||||||
|
|
||||||
|
from confarchive import database
|
||||||
|
|
||||||
|
app = flask.Flask(__name__)
|
||||||
|
app.debug = True
|
||||||
|
|
||||||
|
app.config.from_object("config.default")
|
||||||
|
database.init_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index() -> str:
|
||||||
|
"""Start page."""
|
||||||
|
return flask.render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0")
|
10
templates/index.html
Normal file
10
templates/index.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue